From 0817135971ea4b056a006901e0922c6f48e79cbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Sat, 8 Aug 2026 21:20:14 +0200 Subject: [PATCH 1/5] Add explicit prompt cache breakpoints for OpenAI Responses API OpenAI's automatic prompt caching often misses in practice, showing up as ballooning uncached input tokens in agent sessions. GPT-5.6+ models support explicit cache control via prompt_cache_breakpoint markers on input content blocks, analogous to Anthropic's cache_control. - Extract the Anthropic cache-marker placement (stable prefix via volatile flag, markers every 5 messages) into a shared prompt_caching module and reuse it for both OpenAI Responses clients (HTTP and WebSocket). - Place breakpoints on input_text/input_image blocks; markers anchored on non-eligible items (tool calls/outputs, assistant text) shift backwards to the nearest eligible block. The HTTP client additionally marks the developer/system message. - Gate the field on GPT-5.6+ (older models reject it). - WS client: markers serialize as part of the input items, so compute_delta forces a full resend when marker positions move; the cached token prefix itself stays valid. - Parse cache_write_tokens from usage and normalize OpenAI usage to Anthropic semantics: input_tokens now excludes cached and cache-write tokens (consumers like context-usage tracking already assumed this). --- crates/llm/src/anthropic.rs | 32 +-- crates/llm/src/lib.rs | 1 + crates/llm/src/openai_responses.rs | 372 ++++++++++++++++++++++---- crates/llm/src/openai_responses_ws.rs | 265 +++++++++++++++--- crates/llm/src/prompt_caching.rs | 38 +++ 5 files changed, 585 insertions(+), 123 deletions(-) create mode 100644 crates/llm/src/prompt_caching.rs diff --git a/crates/llm/src/anthropic.rs b/crates/llm/src/anthropic.rs index 8a04f60e..946edaaf 100644 --- a/crates/llm/src/anthropic.rs +++ b/crates/llm/src/anthropic.rs @@ -81,39 +81,9 @@ impl DefaultMessageConverter { Self } - /// Get cache marker positions based on the stable prefix length. - /// - /// Messages at and after the first volatile message are excluded because they - /// may change or disappear between requests, which would invalidate the - /// provider-side cached prefix. - /// - /// 0-4 messages: no cache markers - /// 5-9 messages: marker at index 4 - /// 10-14 messages: markers at indices 4 and 9 - /// 15-19 messages: markers at indices 9 and 14 - /// 20-24 messages: markers at indices 14 and 19 - /// etc. - fn get_cache_marker_positions(&self, messages: &[Message]) -> Vec { - let stable_len = messages - .iter() - .position(|message| message.volatile) - .unwrap_or(messages.len()); - - if stable_len < 5 { - return vec![]; - } - let remainder = stable_len % 5; - let last_marker = stable_len - remainder; - if last_marker > 5 { - vec![last_marker - 6, last_marker - 1] - } else { - vec![last_marker - 1] - } - } - /// Convert generic messages to Anthropic-specific format with cache control fn convert_messages_with_cache(&self, messages: Vec) -> Vec { - let cache_positions = self.get_cache_marker_positions(&messages); + let cache_positions = crate::prompt_caching::cache_marker_positions(&messages); messages .into_iter() diff --git a/crates/llm/src/lib.rs b/crates/llm/src/lib.rs index fdb61ea4..eb3675ca 100644 --- a/crates/llm/src/lib.rs +++ b/crates/llm/src/lib.rs @@ -30,6 +30,7 @@ pub mod openai; pub mod openai_responses; pub mod openai_responses_ws; pub mod openrouter; +pub mod prompt_caching; pub mod provider_config; pub mod recording; pub mod streaming; diff --git a/crates/llm/src/openai_responses.rs b/crates/llm/src/openai_responses.rs index 711fd0f4..13d02b62 100644 --- a/crates/llm/src/openai_responses.rs +++ b/crates/llm/src/openai_responses.rs @@ -204,12 +204,38 @@ struct ModelCapabilities { supports_verbosity: bool, /// Default verbosity for the model default_verbosity: Option, + /// Whether the model supports explicit prompt cache breakpoints + /// (`prompt_cache_breakpoint`, GPT-5.6 and later; older models reject the field) + supports_explicit_cache: bool, +} + +/// Whether a model supports explicit prompt cache breakpoints. +/// +/// Per OpenAI docs, explicit prompt caching is supported by "GPT-5.6 and later +/// model families". Older models reject requests containing +/// `prompt_cache_breakpoint`, so this must stay conservative. +pub(crate) fn model_supports_explicit_cache(model_lower: &str) -> bool { + let Some(pos) = model_lower.find("gpt-") else { + return false; + }; + let version: String = model_lower[pos + 4..] + .chars() + .take_while(|c| c.is_ascii_digit() || *c == '.') + .collect(); + let mut parts = version.split('.'); + let major: u32 = match parts.next().and_then(|p| p.parse().ok()) { + Some(m) => m, + None => return false, + }; + let minor: u32 = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0); + major > 5 || (major == 5 && minor >= 6) } impl ModelCapabilities { /// Get model capabilities based on model ID fn for_model(model: &str) -> Self { let model_lower = model.to_lowercase(); + let supports_explicit_cache = model_supports_explicit_cache(&model_lower); // GPT-5 and variants - full reasoning and verbosity support if model_lower.contains("gpt-5") || model_lower.starts_with("gpt5") { @@ -219,6 +245,7 @@ impl ModelCapabilities { default_summary: Some("auto".to_string()), supports_verbosity: true, default_verbosity: Some(Verbosity::Medium), + supports_explicit_cache, }; } @@ -230,6 +257,7 @@ impl ModelCapabilities { default_summary: Some("auto".to_string()), supports_verbosity: false, default_verbosity: None, + supports_explicit_cache, }; } @@ -241,6 +269,7 @@ impl ModelCapabilities { default_summary: Some("auto".to_string()), supports_verbosity: false, default_verbosity: None, + supports_explicit_cache, }; } @@ -252,6 +281,7 @@ impl ModelCapabilities { default_summary: None, supports_verbosity: false, default_verbosity: None, + supports_explicit_cache, }; } @@ -263,6 +293,7 @@ impl ModelCapabilities { default_summary: None, supports_verbosity: false, default_verbosity: None, + supports_explicit_cache, }; } @@ -274,6 +305,28 @@ impl ModelCapabilities { default_summary: Some("auto".to_string()), supports_verbosity: false, default_verbosity: None, + supports_explicit_cache, + } + } +} + +/// Place an explicit cache breakpoint on the last breakpoint-eligible content +/// block within `items[..end]`. +/// +/// A breakpoint marks the end of the cached prefix including everything +/// rendered before it, but is only valid on input blocks (`input_text`, +/// `input_image`). Function calls, tool outputs and assistant text cannot +/// carry one, so the marker is shifted backwards to the nearest eligible +/// block. The scan only depends on content before the anchor, which is stable +/// across requests, so placement stays deterministic. +fn apply_cache_breakpoint(items: &mut [ResponseInputItem], end: usize) { + for item in items[..end].iter_mut().rev() { + if let ResponseInputItem::Message { content, .. } = item { + for content_item in content.iter_mut().rev() { + if content_item.try_mark_cache_breakpoint() { + return; + } + } } } } @@ -302,15 +355,37 @@ enum ResponseInputItem { }, } +/// Explicit cache breakpoint marker on an input content block (GPT-5.6+). +/// +/// Marks the end of a cacheable prompt prefix, including the marked block and +/// all prompt content rendered before it. Older models reject this field, so +/// it must only be sent when `ModelCapabilities::supports_explicit_cache`. +#[derive(Debug, Clone, Serialize)] +pub(crate) struct PromptCacheBreakpoint { + mode: String, +} + +impl PromptCacheBreakpoint { + pub(crate) fn explicit() -> Self { + Self { + mode: "explicit".to_string(), + } + } +} + /// Content item within messages #[derive(Debug, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] enum ResponseContentItem { InputText { text: String, + #[serde(skip_serializing_if = "Option::is_none")] + prompt_cache_breakpoint: Option, }, InputImage { image_url: String, + #[serde(skip_serializing_if = "Option::is_none")] + prompt_cache_breakpoint: Option, }, OutputText { text: String, @@ -319,6 +394,41 @@ enum ResponseContentItem { }, } +impl ResponseContentItem { + fn input_text(text: String) -> Self { + Self::InputText { + text, + prompt_cache_breakpoint: None, + } + } + + fn input_image(image_url: String) -> Self { + Self::InputImage { + image_url, + prompt_cache_breakpoint: None, + } + } + + /// Set the explicit cache breakpoint if this is an input block that + /// supports one. Returns whether the marker could be applied. + fn try_mark_cache_breakpoint(&mut self) -> bool { + match self { + Self::InputText { + prompt_cache_breakpoint, + .. + } + | Self::InputImage { + prompt_cache_breakpoint, + .. + } => { + *prompt_cache_breakpoint = Some(PromptCacheBreakpoint::explicit()); + true + } + Self::OutputText { .. } => false, + } + } +} + /// Response structure from the Responses API #[derive(Debug, Deserialize)] struct ResponsesResponse { @@ -399,6 +509,32 @@ struct ResponsesUsage { #[derive(Debug, Deserialize)] struct InputTokensDetails { cached_tokens: u32, + /// Tokens written to the prompt cache (explicit caching, GPT-5.6+). + #[serde(default)] + cache_write_tokens: u32, +} + +impl ResponsesUsage { + /// Convert to the provider-independent `Usage`. + /// + /// OpenAI reports `input_tokens` inclusive of cached (and cache-write) + /// tokens, while the internal `Usage` follows Anthropic semantics where + /// `input_tokens` counts only uncached input. + fn to_usage(&self) -> Usage { + let (cache_read, cache_write) = self + .input_tokens_details + .as_ref() + .map(|d| (d.cached_tokens, d.cache_write_tokens)) + .unwrap_or((0, 0)); + Usage { + input_tokens: self + .input_tokens + .saturating_sub(cache_read.saturating_add(cache_write)), + output_tokens: self.output_tokens, + cache_creation_input_tokens: cache_write, + cache_read_input_tokens: cache_read, + } + } } #[derive(Debug, Deserialize)] @@ -607,15 +743,29 @@ impl OpenAIResponsesClient { .customize_url(&self.base_url, streaming) } - /// Convert internal messages to Responses API format - fn convert_messages(&self, messages: Vec) -> Vec { + /// Convert internal messages to Responses API format, optionally placing + /// explicit cache breakpoints at stable history positions. + fn convert_messages_with_cache( + &self, + messages: Vec, + explicit_cache: bool, + ) -> Vec { + let cache_positions = if explicit_cache { + crate::prompt_caching::cache_marker_positions(&messages) + } else { + Vec::new() + }; + let mut result = Vec::new(); + // Number of converted items after each message, so marker positions + // (message indices) can be mapped back to converted input items. + let mut items_after_message = Vec::with_capacity(messages.len()); for message in messages { match message.content { MessageContent::Text(text) => { let content_item = match message.role { - MessageRole::User => ResponseContentItem::InputText { text }, + MessageRole::User => ResponseContentItem::input_text(text), MessageRole::Assistant => ResponseContentItem::OutputText { text, phase: Some("final_answer".to_string()), @@ -633,6 +783,11 @@ impl OpenAIResponsesClient { self.convert_structured_message(message.role, blocks, &mut result); } } + items_after_message.push(result.len()); + } + + for &position in &cache_positions { + apply_cache_breakpoint(&mut result, items_after_message[position]); } result @@ -685,7 +840,7 @@ impl OpenAIResponsesClient { match block { ContentBlock::Text { text, .. } => match role { MessageRole::User => { - current_message_content.push(ResponseContentItem::InputText { text }); + current_message_content.push(ResponseContentItem::input_text(text)); } MessageRole::Assistant => { current_message_content.push(ResponseContentItem::OutputText { @@ -698,12 +853,11 @@ impl OpenAIResponsesClient { media_type, data, .. } => { let image_url = format!("data:{media_type};base64,{data}"); - current_message_content.push(ResponseContentItem::InputImage { image_url }); + current_message_content.push(ResponseContentItem::input_image(image_url)); } ContentBlock::Thinking { thinking, .. } => match role { MessageRole::User => { - current_message_content - .push(ResponseContentItem::InputText { text: thinking }); + current_message_content.push(ResponseContentItem::input_text(thinking)); } MessageRole::Assistant => { current_message_content.push(ResponseContentItem::OutputText { @@ -932,15 +1086,7 @@ impl OpenAIResponsesClient { let content = self.convert_output(responses_response.output); let usage = responses_response .usage - .map_or_else(Usage::zero, |u| Usage { - input_tokens: u.input_tokens, - output_tokens: u.output_tokens, - cache_creation_input_tokens: 0, - cache_read_input_tokens: u - .input_tokens_details - .map(|d| d.cached_tokens) - .unwrap_or(0), - }); + .map_or_else(Usage::zero, |u| u.to_usage()); Ok(LLMResponse { content, @@ -1044,15 +1190,7 @@ impl OpenAIResponsesClient { let content = self.convert_output(responses_response.output); let usage = responses_response .usage - .map_or_else(Usage::zero, |u| Usage { - input_tokens: u.input_tokens, - output_tokens: u.output_tokens, - cache_creation_input_tokens: 0, - cache_read_input_tokens: u - .input_tokens_details - .map(|d| d.cached_tokens) - .unwrap_or(0), - }); + .map_or_else(Usage::zero, |u| u.to_usage()); Ok(( LLMResponse { @@ -1452,12 +1590,7 @@ impl<'a> StreamProcessor<'a> { .clone(), ) { - self.usage.input_tokens = usage_data.input_tokens; - self.usage.output_tokens = usage_data.output_tokens; - self.usage.cache_read_input_tokens = usage_data - .input_tokens_details - .map(|d| d.cached_tokens) - .unwrap_or(0); + *self.usage = usage_data.to_usage(); } // Create a RedactedThinking block from collected reasoning items if any @@ -1489,17 +1622,26 @@ impl LLMProvider for OpenAIResponsesClient { request: LLMRequest, streaming_callback: Option<&StreamingCallback>, ) -> Result { - let mut input = self.convert_messages(request.messages); + // Get model capabilities; explicit cache breakpoints are only sent to + // models that support them (older models reject the field). + let capabilities = ModelCapabilities::for_model(&self.model); + + let mut input = self + .convert_messages_with_cache(request.messages, capabilities.supports_explicit_cache); // Add system prompt as developer message at the beginning if !request.system_prompt.is_empty() { + // The system prompt (plus tools rendered before it) is the largest + // stable prefix — always mark it, mirroring the Anthropic client. + let mut system_item = ResponseContentItem::input_text(request.system_prompt); + if capabilities.supports_explicit_cache { + system_item.try_mark_cache_breakpoint(); + } input.insert( 0, ResponseInputItem::Message { role: "developer".to_string(), - content: vec![ResponseContentItem::InputText { - text: request.system_prompt, - }], + content: vec![system_item], }, ); } @@ -1521,8 +1663,7 @@ impl LLMProvider for OpenAIResponsesClient { // Configure for stateless mode with encrypted reasoning let store = false; - // Get model capabilities and build reasoning config - let capabilities = ModelCapabilities::for_model(&self.model); + // Build reasoning config from model capabilities let reasoning = if capabilities.supports_reasoning { Some(ReasoningConfig { effort: capabilities.default_effort, @@ -1594,7 +1735,7 @@ mod tests { let messages = vec![Message::new_user("Hello")]; - let converted = client.convert_messages(messages); + let converted = client.convert_messages_with_cache(messages, false); assert_eq!(converted.len(), 1); match &converted[0] { @@ -1602,7 +1743,7 @@ mod tests { assert_eq!(role, "user"); assert_eq!(content.len(), 1); match &content[0] { - ResponseContentItem::InputText { text } => { + ResponseContentItem::InputText { text, .. } => { assert_eq!(text, "Hello"); } _ => panic!("Expected InputText"), @@ -1623,7 +1764,7 @@ mod tests { // Test that assistant messages with simple text use OutputText, not InputText let messages = vec![Message::new_assistant("Hello from assistant")]; - let converted = client.convert_messages(messages); + let converted = client.convert_messages_with_cache(messages, false); assert_eq!(converted.len(), 1); match &converted[0] { @@ -1653,7 +1794,7 @@ mod tests { ContentBlock::new_tool_result("test_id", "Tool output"), ])]; - let converted = client.convert_messages(messages); + let converted = client.convert_messages_with_cache(messages, false); assert_eq!(converted.len(), 1); match &converted[0] { @@ -1780,7 +1921,7 @@ mod tests { Message::new_user("What about 3+3?"), ]; - let converted = client.convert_messages(conversation); + let converted = client.convert_messages_with_cache(conversation, false); assert_eq!(converted.len(), 4); // First: User question @@ -1832,7 +1973,7 @@ mod tests { assert_eq!(role, "user"); assert_eq!(content.len(), 1); match &content[0] { - ResponseContentItem::InputText { text } => { + ResponseContentItem::InputText { text, .. } => { assert_eq!(text, "What about 3+3?"); } _ => panic!("Expected InputText"), @@ -1862,7 +2003,7 @@ mod tests { ContentBlock::new_text("Third text"), ])]; - let converted = client.convert_messages(messages); + let converted = client.convert_messages_with_cache(messages, false); assert_eq!(converted.len(), 5); // First: First text @@ -1945,7 +2086,7 @@ mod tests { ContentBlock::new_text("Based on my reasoning, here's the answer."), ])]; - let converted = client.convert_messages(messages); + let converted = client.convert_messages_with_cache(messages, false); assert_eq!(converted.len(), 2); // First should be the encrypted reasoning (maintains original order) @@ -2167,7 +2308,7 @@ mod tests { // Simulate converting messages with a system prompt let messages = vec![Message::new_user("Hello")]; - let mut input = client.convert_messages(messages); + let mut input = client.convert_messages_with_cache(messages, false); // Add system prompt as developer message (simulating what send_message does) let system_prompt = "You are a helpful assistant."; @@ -2176,9 +2317,7 @@ mod tests { 0, ResponseInputItem::Message { role: "developer".to_string(), - content: vec![ResponseContentItem::InputText { - text: system_prompt.to_string(), - }], + content: vec![ResponseContentItem::input_text(system_prompt.to_string())], }, ); } @@ -2191,7 +2330,7 @@ mod tests { assert_eq!(role, "developer"); assert_eq!(content.len(), 1); match &content[0] { - ResponseContentItem::InputText { text } => { + ResponseContentItem::InputText { text, .. } => { assert_eq!(text, "You are a helpful assistant."); } _ => panic!("Expected InputText"), @@ -2206,7 +2345,7 @@ mod tests { assert_eq!(role, "user"); assert_eq!(content.len(), 1); match &content[0] { - ResponseContentItem::InputText { text } => { + ResponseContentItem::InputText { text, .. } => { assert_eq!(text, "Hello"); } _ => panic!("Expected InputText"), @@ -2341,7 +2480,7 @@ mod tests { ); let messages = vec![Message::new_assistant("Hello from assistant")]; - let converted = client.convert_messages(messages); + let converted = client.convert_messages_with_cache(messages, false); assert_eq!(converted.len(), 1); match &converted[0] { @@ -2375,7 +2514,7 @@ mod tests { ContentBlock::new_tool_use("call_1", "search", serde_json::json!({"query": "weather"})), ])]; - let converted = client.convert_messages(messages); + let converted = client.convert_messages_with_cache(messages, false); // Should produce: Message(OutputText), FunctionCall assert_eq!(converted.len(), 2); @@ -2419,7 +2558,7 @@ mod tests { ContentBlock::new_text("Second thought."), ])]; - let converted = client.convert_messages(messages); + let converted = client.convert_messages_with_cache(messages, false); // Message("First thought."), FunctionCall, Message("Second thought.") assert_eq!(converted.len(), 3); @@ -2447,7 +2586,7 @@ mod tests { ); let messages = vec![Message::new_user("Hello")]; - let converted = client.convert_messages(messages); + let converted = client.convert_messages_with_cache(messages, false); assert_eq!(converted.len(), 1); match &converted[0] { @@ -2483,9 +2622,7 @@ mod tests { assert_eq!(json["phase"], "commentary"); // InputText must not carry a phase key - let input_item = ResponseContentItem::InputText { - text: "hello".to_string(), - }; + let input_item = ResponseContentItem::input_text("hello".to_string()); let json = serde_json::to_value(&input_item).unwrap(); assert!(json.get("phase").is_none()); } @@ -2576,4 +2713,123 @@ mod tests { .expect("error should be retryable"); assert!(matches!(ctx.error, ApiError::RateLimit(_))); } + + #[test] + fn test_model_supports_explicit_cache_version_gating() { + // GPT-5.6 and later model families support explicit cache breakpoints + assert!(model_supports_explicit_cache("gpt-5.6")); + assert!(model_supports_explicit_cache("gpt-5.6-codex")); + assert!(model_supports_explicit_cache("gpt-5.7-mini")); + assert!(model_supports_explicit_cache("gpt-6")); + + // Older models reject the field and must not receive it + assert!(!model_supports_explicit_cache("gpt-5")); + assert!(!model_supports_explicit_cache("gpt-5-codex")); + assert!(!model_supports_explicit_cache("gpt-5.1")); + assert!(!model_supports_explicit_cache("gpt-4o")); + assert!(!model_supports_explicit_cache("o3-mini")); + assert!(!model_supports_explicit_cache("o1")); + } + + fn content_items_with_breakpoint(items: &[ResponseInputItem]) -> Vec { + items + .iter() + .enumerate() + .filter_map(|(idx, item)| match item { + ResponseInputItem::Message { content, .. } => content + .iter() + .any(|c| { + matches!( + c, + ResponseContentItem::InputText { + prompt_cache_breakpoint: Some(_), + .. + } | ResponseContentItem::InputImage { + prompt_cache_breakpoint: Some(_), + .. + } + ) + }) + .then_some(idx), + _ => None, + }) + .collect() + } + + #[test] + fn test_explicit_cache_breakpoints_on_user_messages() { + let client = OpenAIResponsesClient::new( + "test_key".to_string(), + "gpt-5.6".to_string(), + "https://api.openai.com/v1".to_string(), + ); + + // 10 user messages → markers at message indices 4 and 9 (1:1 items) + let messages: Vec = (0..10) + .map(|i| Message::new_user(format!("Message {i}"))) + .collect(); + let items = client.convert_messages_with_cache(messages, true); + assert_eq!(content_items_with_breakpoint(&items), vec![4, 9]); + + // Without explicit caching no markers are placed + let messages: Vec = (0..10) + .map(|i| Message::new_user(format!("Message {i}"))) + .collect(); + let items = client.convert_messages_with_cache(messages, false); + assert!(content_items_with_breakpoint(&items).is_empty()); + } + + #[test] + fn test_explicit_cache_breakpoint_shifts_to_eligible_block() { + let client = OpenAIResponsesClient::new( + "test_key".to_string(), + "gpt-5.6".to_string(), + "https://api.openai.com/v1".to_string(), + ); + + // Message index 4 is a tool result (function_call_output, no + // breakpoint support) — the marker must shift back to the nearest + // input block, the user message at index 3. + let messages = vec![ + Message::new_user("Message 0"), + Message::new_assistant("Answer 0"), + Message::new_user("Message 1"), + Message::new_user("Message 2"), + Message::new_user_content(vec![ContentBlock::new_tool_result("call_1", "result")]), + ]; + + let items = client.convert_messages_with_cache(messages, true); + assert_eq!(content_items_with_breakpoint(&items), vec![3]); + } + + #[test] + fn test_prompt_cache_breakpoint_serialization() { + let mut item = ResponseContentItem::input_text("hello".to_string()); + let json = serde_json::to_value(&item).unwrap(); + assert!(json.get("prompt_cache_breakpoint").is_none()); + + assert!(item.try_mark_cache_breakpoint()); + let json = serde_json::to_value(&item).unwrap(); + assert_eq!(json["prompt_cache_breakpoint"]["mode"], "explicit"); + } + + #[test] + fn test_usage_normalization_subtracts_cached_tokens() { + let usage: ResponsesUsage = serde_json::from_value(serde_json::json!({ + "input_tokens": 1000, + "output_tokens": 50, + "total_tokens": 1050, + "input_tokens_details": { + "cached_tokens": 700, + "cache_write_tokens": 200 + } + })) + .unwrap(); + + let converted = usage.to_usage(); + assert_eq!(converted.input_tokens, 100); + assert_eq!(converted.cache_read_input_tokens, 700); + assert_eq!(converted.cache_creation_input_tokens, 200); + assert_eq!(converted.output_tokens, 50); + } } diff --git a/crates/llm/src/openai_responses_ws.rs b/crates/llm/src/openai_responses_ws.rs index 6d4b1a7b..9f7f4b7c 100644 --- a/crates/llm/src/openai_responses_ws.rs +++ b/crates/llm/src/openai_responses_ws.rs @@ -52,7 +52,7 @@ use tokio_tungstenite::{ use tracing::{debug, info, warn}; // Re-export types shared with the HTTP provider -use crate::openai_responses::Verbosity; +use crate::openai_responses::{PromptCacheBreakpoint, Verbosity, model_supports_explicit_cache}; // ============================================================================ // Request / Response types (WebSocket-specific envelope) @@ -145,9 +145,13 @@ enum WsInputItem { enum WsContentItem { InputText { text: String, + #[serde(skip_serializing_if = "Option::is_none")] + prompt_cache_breakpoint: Option, }, InputImage { image_url: String, + #[serde(skip_serializing_if = "Option::is_none")] + prompt_cache_breakpoint: Option, }, OutputText { text: String, @@ -156,6 +160,66 @@ enum WsContentItem { }, } +impl WsContentItem { + fn input_text(text: String) -> Self { + Self::InputText { + text, + prompt_cache_breakpoint: None, + } + } + + fn input_image(image_url: String) -> Self { + Self::InputImage { + image_url, + prompt_cache_breakpoint: None, + } + } + + /// Set the explicit cache breakpoint if this is an input block that + /// supports one. Returns whether the marker could be applied. + fn try_mark_cache_breakpoint(&mut self) -> bool { + match self { + Self::InputText { + prompt_cache_breakpoint, + .. + } + | Self::InputImage { + prompt_cache_breakpoint, + .. + } => { + *prompt_cache_breakpoint = Some(PromptCacheBreakpoint::explicit()); + true + } + Self::OutputText { .. } => false, + } + } +} + +/// Place an explicit cache breakpoint on the last breakpoint-eligible content +/// block within `items[..end]`. +/// +/// A breakpoint marks the end of the cached prefix including everything +/// rendered before it, but is only valid on input blocks (`input_text`, +/// `input_image`). Function calls, tool outputs and assistant text cannot +/// carry one, so the marker is shifted backwards to the nearest eligible +/// block. The scan only depends on content before the anchor, which is stable +/// across requests, so placement stays deterministic. +/// +/// Markers serialize as part of the input items, so `compute_delta` naturally +/// forces a full resend whenever marker positions move — the previously +/// cached token prefix stays valid because markers are not prompt content. +fn apply_cache_breakpoint(items: &mut [WsInputItem], end: usize) { + for item in items[..end].iter_mut().rev() { + if let WsInputItem::Message { content, .. } = item { + for content_item in content.iter_mut().rev() { + if content_item.try_mark_cache_breakpoint() { + return; + } + } + } + } +} + // --------------------------------------------------------------------------- // Output / event types (received from the API) // --------------------------------------------------------------------------- @@ -249,6 +313,32 @@ struct WsResponsesUsage { #[derive(Debug, Deserialize)] struct WsInputTokensDetails { cached_tokens: u32, + /// Tokens written to the prompt cache (explicit caching, GPT-5.6+). + #[serde(default)] + cache_write_tokens: u32, +} + +impl WsResponsesUsage { + /// Convert to the provider-independent `Usage`. + /// + /// OpenAI reports `input_tokens` inclusive of cached (and cache-write) + /// tokens, while the internal `Usage` follows Anthropic semantics where + /// `input_tokens` counts only uncached input. + fn to_usage(&self) -> Usage { + let (cache_read, cache_write) = self + .input_tokens_details + .as_ref() + .map(|d| (d.cached_tokens, d.cache_write_tokens)) + .unwrap_or((0, 0)); + Usage { + input_tokens: self + .input_tokens + .saturating_sub(cache_read.saturating_add(cache_write)), + output_tokens: self.output_tokens, + cache_creation_input_tokens: cache_write, + cache_read_input_tokens: cache_read, + } + } } // ============================================================================ @@ -262,11 +352,15 @@ struct ModelCapabilities { default_summary: Option, supports_verbosity: bool, default_verbosity: Option, + /// Whether the model supports explicit prompt cache breakpoints + /// (`prompt_cache_breakpoint`, GPT-5.6 and later; older models reject the field) + supports_explicit_cache: bool, } impl ModelCapabilities { fn for_model(model: &str) -> Self { let m = model.to_lowercase(); + let supports_explicit_cache = model_supports_explicit_cache(&m); if m.contains("gpt-5") || m.starts_with("gpt5") { return Self { @@ -275,6 +369,7 @@ impl ModelCapabilities { default_summary: Some("auto".into()), supports_verbosity: true, default_verbosity: Some(Verbosity::Medium), + supports_explicit_cache, }; } if m.starts_with("o3") || m.starts_with("o4") { @@ -284,6 +379,7 @@ impl ModelCapabilities { default_summary: Some("auto".into()), supports_verbosity: false, default_verbosity: None, + supports_explicit_cache, }; } if m.starts_with("o1") { @@ -293,6 +389,7 @@ impl ModelCapabilities { default_summary: Some("auto".into()), supports_verbosity: false, default_verbosity: None, + supports_explicit_cache, }; } if m.contains("gpt-4o") || m.contains("gpt4o") || m.contains("gpt-4") || m.contains("gpt4") @@ -303,6 +400,7 @@ impl ModelCapabilities { default_summary: None, supports_verbosity: false, default_verbosity: None, + supports_explicit_cache, }; } // Default: assume reasoning support with conservative defaults @@ -312,6 +410,7 @@ impl ModelCapabilities { default_summary: Some("auto".into()), supports_verbosity: false, default_verbosity: None, + supports_explicit_cache, } } } @@ -587,8 +686,24 @@ impl OpenAIResponsesWsClient { // Message conversion (internal types → WS input items) // ----------------------------------------------------------------------- - fn convert_messages(&self, messages: Vec) -> Vec { + /// Convert internal messages to WS input items, optionally placing + /// explicit cache breakpoints at stable history positions. + fn convert_messages_with_cache( + &self, + messages: Vec, + explicit_cache: bool, + ) -> Vec { + let cache_positions = if explicit_cache { + crate::prompt_caching::cache_marker_positions(&messages) + } else { + Vec::new() + }; + let mut items = Vec::new(); + // Number of converted items after each message, so marker positions + // (message indices) can be mapped back to converted input items. + let mut items_after_message = Vec::with_capacity(messages.len()); + for message in messages { match &message.content { MessageContent::Text(text) => { @@ -605,7 +720,7 @@ impl OpenAIResponsesWsClient { phase: Some("final_answer".to_string()), } } else { - WsContentItem::InputText { text: text.clone() } + WsContentItem::input_text(text.clone()) }; items.push(WsInputItem::Message { role: role.to_string(), @@ -616,7 +731,13 @@ impl OpenAIResponsesWsClient { self.convert_structured_message(&message.role, blocks, &mut items); } } + items_after_message.push(items.len()); } + + for &position in &cache_positions { + apply_cache_breakpoint(&mut items, items_after_message[position]); + } + items } @@ -652,7 +773,7 @@ impl OpenAIResponsesWsClient { match block { ContentBlock::Text { text, .. } => { let item = if *role == MessageRole::User { - WsContentItem::InputText { text: text.clone() } + WsContentItem::input_text(text.clone()) } else { WsContentItem::OutputText { text: text.clone(), @@ -662,9 +783,10 @@ impl OpenAIResponsesWsClient { current_content.push(item); } ContentBlock::Image { data, .. } => { - current_content.push(WsContentItem::InputImage { - image_url: format!("data:image/png;base64,{}", data), - }); + current_content.push(WsContentItem::input_image(format!( + "data:image/png;base64,{}", + data + ))); } ContentBlock::Thinking { thinking, @@ -906,7 +1028,15 @@ impl OpenAIResponsesWsClient { request: LLMRequest, streaming_callback: Option<&StreamingCallback>, ) -> Result { - let input = self.convert_messages(request.messages); + let capabilities = ModelCapabilities::for_model(&self.model); + + // Explicit cache breakpoints are only sent to models that support + // them (older models reject the field). The system prompt travels as + // `instructions` (a plain string, no breakpoint possible); a marker on + // a history message still caches instructions and tools, since a + // breakpoint covers everything rendered before it. + let input = self + .convert_messages_with_cache(request.messages, capabilities.supports_explicit_cache); // Add system prompt as the top-level `instructions` field (WebSocket style) // but also keep it as a developer message in `input` for compatibility. @@ -930,8 +1060,6 @@ impl OpenAIResponsesWsClient { .collect() }); - let capabilities = ModelCapabilities::for_model(&self.model); - let reasoning = if capabilities.supports_reasoning { Some(ReasoningConfig { effort: capabilities.default_effort, @@ -1277,12 +1405,7 @@ impl OpenAIResponsesWsClient { usage_val.clone(), ) { - usage.input_tokens = u.input_tokens; - usage.output_tokens = u.output_tokens; - usage.cache_read_input_tokens = u - .input_tokens_details - .map(|d| d.cached_tokens) - .unwrap_or(0); + usage = u.to_usage(); } } @@ -1632,9 +1755,7 @@ mod tests { previous_response_id: None, input: vec![WsInputItem::Message { role: "user".to_string(), - content: vec![WsContentItem::InputText { - text: "Hello".to_string(), - }], + content: vec![WsContentItem::input_text("Hello".to_string())], }], tools: None, tool_choice: Some("auto".to_string()), @@ -1663,9 +1784,7 @@ mod tests { previous_response_id: Some("resp_abc123".to_string()), input: vec![WsInputItem::Message { role: "user".to_string(), - content: vec![WsContentItem::InputText { - text: "Follow-up".to_string(), - }], + content: vec![WsContentItem::input_text("Follow-up".to_string())], }], tools: None, tool_choice: None, @@ -1709,9 +1828,7 @@ mod tests { // No previous state => no delta let input = vec![WsInputItem::Message { role: "user".to_string(), - content: vec![WsContentItem::InputText { - text: "Hello".to_string(), - }], + content: vec![WsContentItem::input_text("Hello".to_string())], }]; assert!(client.compute_delta(&input).is_none()); @@ -1730,9 +1847,7 @@ mod tests { }); extended.push(WsInputItem::Message { role: "user".to_string(), - content: vec![WsContentItem::InputText { - text: "How are you?".to_string(), - }], + content: vec![WsContentItem::input_text("How are you?".to_string())], }); let result = client.compute_delta(&extended); @@ -1832,7 +1947,7 @@ mod tests { ); let messages = vec![Message::new_assistant("Hello from assistant")]; - let converted = client.convert_messages(messages); + let converted = client.convert_messages_with_cache(messages, false); assert_eq!(converted.len(), 1); match &converted[0] { @@ -1866,7 +1981,7 @@ mod tests { ContentBlock::new_tool_use("call_1", "search", serde_json::json!({"query": "weather"})), ])]; - let converted = client.convert_messages(messages); + let converted = client.convert_messages_with_cache(messages, false); // Should produce: Message(OutputText), FunctionCall assert_eq!(converted.len(), 2); @@ -1897,7 +2012,7 @@ mod tests { ); let messages = vec![Message::new_user("Hello")]; - let converted = client.convert_messages(messages); + let converted = client.convert_messages_with_cache(messages, false); assert_eq!(converted.len(), 1); match &converted[0] { @@ -1931,10 +2046,92 @@ mod tests { let json = serde_json::to_value(&commentary_item).unwrap(); assert_eq!(json["phase"], "commentary"); - let input_item = WsContentItem::InputText { - text: "hello".to_string(), - }; + let input_item = WsContentItem::input_text("hello".to_string()); let json = serde_json::to_value(&input_item).unwrap(); assert!(json.get("phase").is_none()); } + + fn ws_items_with_breakpoint(items: &[WsInputItem]) -> Vec { + items + .iter() + .enumerate() + .filter_map(|(idx, item)| match item { + WsInputItem::Message { content, .. } => content + .iter() + .any(|c| { + matches!( + c, + WsContentItem::InputText { + prompt_cache_breakpoint: Some(_), + .. + } | WsContentItem::InputImage { + prompt_cache_breakpoint: Some(_), + .. + } + ) + }) + .then_some(idx), + _ => None, + }) + .collect() + } + + #[test] + fn test_explicit_cache_breakpoints_on_user_messages() { + let client = OpenAIResponsesWsClient::new( + "test_key".to_string(), + "gpt-5.6".to_string(), + "https://api.openai.com/v1".to_string(), + ); + + // 10 user messages → markers at message indices 4 and 9 (1:1 items) + let messages: Vec = (0..10) + .map(|i| Message::new_user(format!("Message {i}"))) + .collect(); + let items = client.convert_messages_with_cache(messages, true); + assert_eq!(ws_items_with_breakpoint(&items), vec![4, 9]); + + // Without explicit caching no markers are placed + let messages: Vec = (0..10) + .map(|i| Message::new_user(format!("Message {i}"))) + .collect(); + let items = client.convert_messages_with_cache(messages, false); + assert!(ws_items_with_breakpoint(&items).is_empty()); + } + + /// Moving cache markers must invalidate the incremental-input delta so + /// the full input (with markers at their new positions) is resent. + #[test] + fn test_moved_cache_marker_invalidates_delta() { + let mut client = OpenAIResponsesWsClient::new( + "test_key".to_string(), + "gpt-5.6".to_string(), + "https://api.openai.com/v1".to_string(), + ); + + let messages: Vec = (0..10) + .map(|i| Message::new_user(format!("Message {i}"))) + .collect(); + let prev_items = client.convert_messages_with_cache(messages.clone(), true); + client.last_response_id = Some("resp_1".to_string()); + client.last_input_items = prev_items; + + // Growing to 12 messages keeps markers at 4 and 9 → strict extension + let mut extended = messages.clone(); + extended.push(Message::new_user("Message 10")); + extended.push(Message::new_user("Message 11")); + let items = client.convert_messages_with_cache(extended, true); + let delta = client.compute_delta(&items); + assert!(delta.is_some()); + assert_eq!(delta.unwrap().1.len(), 2); + + // Growing to 15 messages moves markers to 9 and 14 → full resend + let mut extended = messages; + for i in 10..15 { + extended.push(Message::new_user(format!("Message {i}"))); + } + let items = client.convert_messages_with_cache(extended, true); + assert_eq!(ws_items_with_breakpoint(&items), vec![9, 14]); + assert!(client.compute_delta(&items).is_none()); + } } diff --git a/crates/llm/src/prompt_caching.rs b/crates/llm/src/prompt_caching.rs new file mode 100644 index 00000000..08df231d --- /dev/null +++ b/crates/llm/src/prompt_caching.rs @@ -0,0 +1,38 @@ +//! Shared cache-marker placement logic for providers with explicit prompt caching. +//! +//! Both the Anthropic client and the OpenAI Responses clients place explicit +//! cache breakpoints into the message history. The placement strategy is +//! provider-independent: markers anchor at message indices derived from the +//! length of the stable (non-volatile) history prefix. + +use crate::types::Message; + +/// Get cache marker positions based on the stable prefix length. +/// +/// Messages at and after the first volatile message are excluded because they +/// may change or disappear between requests, which would invalidate the +/// provider-side cached prefix. +/// +/// 0-4 messages: no cache markers +/// 5-9 messages: marker at index 4 +/// 10-14 messages: markers at indices 4 and 9 +/// 15-19 messages: markers at indices 9 and 14 +/// 20-24 messages: markers at indices 14 and 19 +/// etc. +pub fn cache_marker_positions(messages: &[Message]) -> Vec { + let stable_len = messages + .iter() + .position(|message| message.volatile) + .unwrap_or(messages.len()); + + if stable_len < 5 { + return vec![]; + } + let remainder = stable_len % 5; + let last_marker = stable_len - remainder; + if last_marker > 5 { + vec![last_marker - 6, last_marker - 1] + } else { + vec![last_marker - 1] + } +} From 091abf6097801a2684fa442d65cf2b4d473e1a81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Sat, 8 Aug 2026 22:06:29 +0200 Subject: [PATCH 2/5] Move Anthropic cache markers on every request instead of every 5 messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quantized scheme left the tail behind the last marker (up to ~5 messages, often including large tool results) billed at full input price on several consecutive requests. A cache write (1.25x) pays for itself as soon as the prefix is reused once, so caching as early as possible is strictly cheaper for any conversation that continues. New stateless placement: leading marker on the last stable message, trailing marker on the message directly before the last stable assistant message. Since every request ends on a user message and the response is appended right behind it, that position is exactly where the previous request placed its leading marker — the lookup is guaranteed to hit and only the newly appended messages are written, regardless of how many messages arrived in between (tool results, pending user messages). The OpenAI Responses clients keep the quantized scheme: implicit mode already caches up to the latest message on every request, and rarely-moving markers keep the WS client's incremental input intact (per-request movement would force a full resend every turn). --- crates/llm/src/anthropic.rs | 200 +++++++++++++------------------ crates/llm/src/prompt_caching.rs | 119 ++++++++++++++++-- 2 files changed, 195 insertions(+), 124 deletions(-) diff --git a/crates/llm/src/anthropic.rs b/crates/llm/src/anthropic.rs index 946edaaf..f1eb0154 100644 --- a/crates/llm/src/anthropic.rs +++ b/crates/llm/src/anthropic.rs @@ -83,7 +83,7 @@ impl DefaultMessageConverter { /// Convert generic messages to Anthropic-specific format with cache control fn convert_messages_with_cache(&self, messages: Vec) -> Vec { - let cache_positions = crate::prompt_caching::cache_marker_positions(&messages); + let cache_positions = crate::prompt_caching::per_request_marker_positions(&messages); messages .into_iter() @@ -1402,99 +1402,55 @@ mod tests { use serde_json::json; - /// Test cache marker placement based on message count (stateless) + /// Test per-request cache marker placement (stateless): leading marker on + /// the last message, trailing marker directly before the last assistant + /// message — i.e. on the previous request's leading marker position. #[test] - fn test_message_count_based_cache_markers() { + fn test_per_request_cache_markers() { let converter = DefaultMessageConverter::new(); // Helper to count cache markers in messages fn count_message_cache_markers(result: &[AnthropicMessage]) -> Vec { - let mut marker_positions = Vec::new(); - for (msg_idx, msg) in result.iter().enumerate() { - if msg - .content - .iter() - .any(|block| block.cache_control.is_some()) - { - marker_positions.push(msg_idx); - } - } - marker_positions - } - - // Test 0-4 messages: No cache markers - for msg_count in 0..=4 { - let messages: Vec = (0..msg_count) - .map(|i| Message::new_user(format!("Message {i}"))) - .collect(); - - let result = converter.convert_messages_with_cache(messages); - let markers = count_message_cache_markers(&result); - assert!( - markers.is_empty(), - "{msg_count} messages: Should have no cache markers" - ); - } - - // Test 5-9 messages: Cache marker at index 4 - for msg_count in 5..=9 { - let messages: Vec = (0..msg_count) - .map(|i| Message::new_user(format!("Message {i}"))) - .collect(); - - let result = converter.convert_messages_with_cache(messages); - let markers = count_message_cache_markers(&result); - assert_eq!( - markers, - vec![4], - "{msg_count} messages: Should have cache marker at index 4" - ); - } - - // Test 10-14 messages: Cache markers at indices 4 and 9 - for msg_count in 10..=14 { - let messages: Vec = (0..msg_count) - .map(|i| Message::new_user(format!("Message {i}"))) - .collect(); - - let result = converter.convert_messages_with_cache(messages); - let markers = count_message_cache_markers(&result); - assert_eq!( - markers, - vec![4, 9], - "{msg_count} messages: Should have cache markers at indices 4 and 9" - ); - } - - // Test 15-19 messages: Cache markers at indices 9 and 14 - for msg_count in 15..=19 { - let messages: Vec = (0..msg_count) - .map(|i| Message::new_user(format!("Message {i}"))) - .collect(); - - let result = converter.convert_messages_with_cache(messages); - let markers = count_message_cache_markers(&result); - assert_eq!( - markers, - vec![9, 14], - "{msg_count} messages: Should have cache markers at indices 9 and 14" - ); + result + .iter() + .enumerate() + .filter_map(|(idx, msg)| { + msg.content + .iter() + .any(|block| block.cache_control.is_some()) + .then_some(idx) + }) + .collect() } - // Test 20-24 messages: Cache markers at indices 14 and 19 - for msg_count in 20..=24 { - let messages: Vec = (0..msg_count) - .map(|i| Message::new_user(format!("Message {i}"))) - .collect(); + // No history → no markers + let result = converter.convert_messages_with_cache(vec![]); + assert!(count_message_cache_markers(&result).is_empty()); - let result = converter.convert_messages_with_cache(messages); - let markers = count_message_cache_markers(&result); - assert_eq!( - markers, - vec![14, 19], - "{msg_count} messages: Should have cache markers at indices 14 and 19" - ); - } + // User-only history → single marker on the last message + let messages: Vec = (0..3) + .map(|i| Message::new_user(format!("Message {i}"))) + .collect(); + let result = converter.convert_messages_with_cache(messages); + assert_eq!(count_message_cache_markers(&result), vec![2]); + + // Growing conversation: each request's trailing marker must land on + // the previous request's leading marker position + let mut messages = vec![Message::new_user("u0")]; + let result = converter.convert_messages_with_cache(messages.clone()); + assert_eq!(count_message_cache_markers(&result), vec![0]); + + messages.push(Message::new_assistant("a1")); + messages.push(Message::new_user("u2")); + let result = converter.convert_messages_with_cache(messages.clone()); + assert_eq!(count_message_cache_markers(&result), vec![0, 2]); + + // Pending user message appended on top of the regular turn + messages.push(Message::new_assistant("a3")); + messages.push(Message::new_user("u4")); + messages.push(Message::new_user("u5 (pending)")); + let result = converter.convert_messages_with_cache(messages); + assert_eq!(count_message_cache_markers(&result), vec![2, 5]); } /// Test that volatile messages cap the cacheable history prefix. @@ -1515,25 +1471,33 @@ mod tests { .collect() } - let mut messages: Vec = (0..18) - .map(|i| Message::new_user(format!("Message {i}"))) - .collect(); + fn alternating_messages(count: usize) -> Vec { + (0..count) + .map(|i| { + if i % 2 == 0 { + Message::new_user(format!("u{i}")) + } else { + Message::new_assistant(format!("a{i}")) + } + }) + .collect() + } + + let mut messages = alternating_messages(18); messages[12].volatile = true; let result = converter.convert_messages_with_cache(messages); assert_eq!( count_message_cache_markers(&result), - vec![4, 9], - "18 messages with first volatile at 12 should use markers for the 12-message stable prefix" + vec![10, 11], + "First volatile at 12 caps markers to the 12-message stable prefix" ); - let mut messages: Vec = (0..18) - .map(|i| Message::new_user(format!("Message {i}"))) - .collect(); - messages[4].volatile = true; + let mut messages = alternating_messages(18); + messages[0].volatile = true; let result = converter.convert_messages_with_cache(messages); assert!( count_message_cache_markers(&result).is_empty(), - "A volatile message before index 5 should suppress message-history cache markers" + "A fully volatile history should carry no cache markers" ); } @@ -1569,7 +1533,8 @@ mod tests { let result = converter.convert_messages_with_cache(messages); - // Should have cache markers at indices 9 and 14 + // Leading marker on the last message (14), trailing marker directly + // before the last assistant message (13) → index 12 let mut cache_markers = Vec::new(); for (idx, msg) in result.iter().enumerate() { if msg @@ -1582,8 +1547,8 @@ mod tests { } assert_eq!( cache_markers, - vec![9, 14], - "15 messages should have cache markers at indices 9 and 14" + vec![12, 14], + "15 messages should have cache markers at indices 12 and 14" ); // Verify structured content is preserved @@ -1663,7 +1628,7 @@ mod tests { }) .collect(); - // Both should have identical cache marker placement (indices 9, 14) + // Both should have identical cache marker placement (indices 12, 14) let result_a = converter.convert_messages_with_cache(messages_a); let result_b = converter.convert_messages_with_cache(messages_b); @@ -1691,13 +1656,13 @@ mod tests { assert_eq!( markers_a, - vec![9, 14], - "Message set A should have markers at indices 9, 14" + vec![12, 14], + "Message set A should have markers at indices 12, 14" ); assert_eq!( markers_b, - vec![9, 14], - "Message set B should have markers at indices 9, 14" + vec![12, 14], + "Message set B should have markers at indices 12, 14" ); assert_eq!( markers_a, markers_b, @@ -1713,8 +1678,8 @@ mod tests { let markers_short = get_markers(&result_short); assert_eq!( markers_short, - vec![4], - "7 messages should have marker at index 4" + vec![6], + "7 user-only messages should have a single marker on the last one" ); } @@ -1795,13 +1760,14 @@ mod tests { .collect() }; - // Test with the full 30-message conversation + // Test with the full 30-message conversation: leading marker on the + // last message (29), trailing before the last assistant message (28) let result30 = converter.convert_messages_with_cache(messages.clone()); let markers30 = get_markers(&result30); assert_eq!( markers30, - vec![24, 29], - "30 messages: Should have cache markers at indices 24 and 29, found: {markers30:?}" + vec![27, 29], + "30 messages: Should have cache markers at indices 27 and 29, found: {markers30:?}" ); // Test different message counts to demonstrate stateless behavior @@ -1813,8 +1779,8 @@ mod tests { let markers_short = get_markers(&result_short); assert_eq!( markers_short, - vec![4], - "7 messages should have marker at index 4" + vec![3, 6], + "7 messages should have markers at indices 3 and 6" ); // Agent 2: Medium conversation (12 messages) @@ -1823,8 +1789,8 @@ mod tests { let markers_medium = get_markers(&result_medium); assert_eq!( markers_medium, - vec![4, 9], - "12 messages should have markers at indices 4, 9" + vec![9, 11], + "12 messages should have markers at indices 9, 11" ); // Agent 3: Long conversation (18 messages) @@ -1833,8 +1799,8 @@ mod tests { let markers_long = get_markers(&result_long); assert_eq!( markers_long, - vec![9, 14], - "18 messages should have markers at indices 9, 14" + vec![15, 17], + "18 messages should have markers at indices 15, 17" ); // Agent 4: Very long conversation (25 messages) @@ -1843,8 +1809,8 @@ mod tests { let markers_very_long = get_markers(&result_very_long); assert_eq!( markers_very_long, - vec![19, 24], - "25 messages should have markers at indices 19, 24" + vec![21, 24], + "25 messages should have markers at indices 21, 24" ); // Verify that cache markers are only placed on first content block of structured messages diff --git a/crates/llm/src/prompt_caching.rs b/crates/llm/src/prompt_caching.rs index 08df231d..c7f63d9e 100644 --- a/crates/llm/src/prompt_caching.rs +++ b/crates/llm/src/prompt_caching.rs @@ -3,15 +3,67 @@ //! Both the Anthropic client and the OpenAI Responses clients place explicit //! cache breakpoints into the message history. The placement strategy is //! provider-independent: markers anchor at message indices derived from the -//! length of the stable (non-volatile) history prefix. +//! stable (non-volatile) history prefix. -use crate::types::Message; +use crate::types::{Message, MessageRole}; -/// Get cache marker positions based on the stable prefix length. +/// Length of the stable history prefix. /// /// Messages at and after the first volatile message are excluded because they /// may change or disappear between requests, which would invalidate the /// provider-side cached prefix. +fn stable_prefix_len(messages: &[Message]) -> usize { + messages + .iter() + .position(|message| message.volatile) + .unwrap_or(messages.len()) +} + +/// Get cache marker positions that move on every request. Used by the +/// Anthropic client, which resends the full message history each request. +/// +/// Two markers are placed inside the stable prefix: +/// +/// - the last stable message, so the entire prompt built in this request is +/// cached as early as possible (a write pays for itself as soon as the +/// prefix is reused once: 1.25x + 0.1x < 1x + 1x) +/// - the message directly before the last stable assistant message +/// +/// Every request ends on a user message and the response is appended right +/// behind it as an assistant message, so the message before the last +/// assistant message is exactly where the previous request placed its leading +/// marker. The lookup there is guaranteed to hit and only the messages +/// appended since then are written — without tracking any state, and +/// regardless of how many messages arrived in between (tool results, pending +/// user messages). +pub fn per_request_marker_positions(messages: &[Message]) -> Vec { + let stable_len = stable_prefix_len(messages); + if stable_len == 0 { + return vec![]; + } + + let leading = stable_len - 1; + let trailing = messages[..stable_len] + .iter() + .rposition(|message| message.role == MessageRole::Assistant) + .and_then(|assistant_index| assistant_index.checked_sub(1)); + + match trailing { + // `trailing < leading` always holds: the assistant message itself + // sits between the two positions. + Some(trailing) => vec![trailing, leading], + None => vec![leading], + } +} + +/// Get cache marker positions quantized to blocks of five messages. Used by +/// the OpenAI Responses clients. +/// +/// There the markers are stable *anchors* rather than the primary caching +/// mechanism: OpenAI's implicit mode already caches up to the latest message +/// on every request, and rarely-moving markers keep the WebSocket client's +/// incremental input intact (moving a marker changes an already-sent item and +/// forces a full resend). /// /// 0-4 messages: no cache markers /// 5-9 messages: marker at index 4 @@ -20,10 +72,7 @@ use crate::types::Message; /// 20-24 messages: markers at indices 14 and 19 /// etc. pub fn cache_marker_positions(messages: &[Message]) -> Vec { - let stable_len = messages - .iter() - .position(|message| message.volatile) - .unwrap_or(messages.len()); + let stable_len = stable_prefix_len(messages); if stable_len < 5 { return vec![]; @@ -36,3 +85,59 @@ pub fn cache_marker_positions(messages: &[Message]) -> Vec { vec![last_marker - 1] } } + +#[cfg(test)] +mod tests { + use super::*; + + /// The trailing marker of each request must land on the leading marker + /// position of the previous request, so that cache lookups always hit. + #[test] + fn per_request_markers_chain_across_requests() { + // Request 1: a single user message → one marker + let mut messages = vec![Message::new_user("u0")]; + assert_eq!(per_request_marker_positions(&messages), vec![0]); + + // Response appended, tool results form the next request + messages.push(Message::new_assistant("a1")); + messages.push(Message::new_user("u2")); + assert_eq!(per_request_marker_positions(&messages), vec![0, 2]); + + // Next turn adds a pending user message on top — the trailing marker + // still lands on the previous request's leading position (index 2) + messages.push(Message::new_assistant("a3")); + messages.push(Message::new_user("u4")); + messages.push(Message::new_user("u5 (pending)")); + assert_eq!(per_request_marker_positions(&messages), vec![2, 5]); + } + + #[test] + fn per_request_markers_respect_volatile_prefix() { + let mut messages: Vec = (0..18) + .map(|i| { + if i % 2 == 0 { + Message::new_user(format!("u{i}")) + } else { + Message::new_assistant(format!("a{i}")) + } + }) + .collect(); + assert_eq!(per_request_marker_positions(&messages), vec![16, 17]); + + // Volatile tail caps the markable prefix + messages[12].volatile = true; + assert_eq!(per_request_marker_positions(&messages), vec![10, 11]); + + // Everything volatile → no markers + messages[0].volatile = true; + assert!(per_request_marker_positions(&messages).is_empty()); + } + + #[test] + fn per_request_markers_without_assistant_history() { + assert!(per_request_marker_positions(&[]).is_empty()); + + let messages: Vec = (0..3).map(|i| Message::new_user(format!("u{i}"))).collect(); + assert_eq!(per_request_marker_positions(&messages), vec![2]); + } +} From fcc9bf9fc38644826e67d1d17f2c24d3bc0f251d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Sun, 9 Aug 2026 22:27:51 +0200 Subject: [PATCH 3/5] Consume gpui-component markdown-source-copy-consume-v3 Bumps gpui-component to ab37c62a (markdown-source-copy-consume-v3): markdown-source-copy + the copy-beyond-viewport fix (select the full geometric band, not just visible glyphs) + the zed 1a246efd pin. Also adapts BlockView::markdown_view to the SelectionFormat API (.selectable_source() -> .selection_format()). --- Cargo.lock | 24 ++++++++++++------------ Cargo.toml | 2 +- crates/ui_gpui/src/blocks/mod.rs | 9 +++++++-- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b57fd5c9..abac2918 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2536,7 +2536,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2816,7 +2816,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4528,7 +4528,7 @@ dependencies = [ [[package]] name = "gpui-component" version = "0.5.2" -source = "git+https://github.com/stippi/gpui-component?rev=1a66dc72424ae70300aae2ee02b04c4ce4e71d5a#1a66dc72424ae70300aae2ee02b04c4ce4e71d5a" +source = "git+https://github.com/stippi/gpui-component?rev=ab37c62a6be0e9315deafa39bde1ae9f2c0894eb#ab37c62a6be0e9315deafa39bde1ae9f2c0894eb" dependencies = [ "aho-corasick", "anyhow", @@ -4576,7 +4576,7 @@ dependencies = [ [[package]] name = "gpui-component-assets" version = "0.5.1" -source = "git+https://github.com/stippi/gpui-component?rev=1a66dc72424ae70300aae2ee02b04c4ce4e71d5a#1a66dc72424ae70300aae2ee02b04c4ce4e71d5a" +source = "git+https://github.com/stippi/gpui-component?rev=ab37c62a6be0e9315deafa39bde1ae9f2c0894eb#ab37c62a6be0e9315deafa39bde1ae9f2c0894eb" dependencies = [ "anyhow", "gpui", @@ -4590,7 +4590,7 @@ dependencies = [ [[package]] name = "gpui-component-macros" version = "0.5.1" -source = "git+https://github.com/stippi/gpui-component?rev=1a66dc72424ae70300aae2ee02b04c4ce4e71d5a#1a66dc72424ae70300aae2ee02b04c4ce4e71d5a" +source = "git+https://github.com/stippi/gpui-component?rev=ab37c62a6be0e9315deafa39bde1ae9f2c0894eb#ab37c62a6be0e9315deafa39bde1ae9f2c0894eb" dependencies = [ "proc-macro2", "quote", @@ -5313,7 +5313,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.57.0", + "windows-core 0.62.2", ] [[package]] @@ -5751,7 +5751,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6818,7 +6818,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8311,7 +8311,7 @@ dependencies = [ "once_cell", "socket2 0.6.3", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -9002,7 +9002,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -10424,7 +10424,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -12224,7 +12224,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.48.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 31a432a6..b21b4e7e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,4 +25,4 @@ ratatui = { git = "https://github.com/nornagon/ratatui", branch = "nornagon-v0.2 gpui = { git = "https://github.com/zed-industries/zed", rev = "1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba", version = "=0.2.2" } gpui_platform = { git = "https://github.com/zed-industries/zed", rev = "1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" } gpui_macros = { git = "https://github.com/zed-industries/zed", rev = "1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" } -gpui-component = { git = "https://github.com/stippi/gpui-component", rev = "1a66dc72424ae70300aae2ee02b04c4ce4e71d5a" } +gpui-component = { git = "https://github.com/stippi/gpui-component", rev = "ab37c62a6be0e9315deafa39bde1ae9f2c0894eb" } diff --git a/crates/ui_gpui/src/blocks/mod.rs b/crates/ui_gpui/src/blocks/mod.rs index 8c6cf968..abfdd38b 100644 --- a/crates/ui_gpui/src/blocks/mod.rs +++ b/crates/ui_gpui/src/blocks/mod.rs @@ -7,7 +7,7 @@ pub use data::*; use gpui::prelude::*; use gpui::{Context, Entity, Pixels, Task, px}; -use gpui_component::text::{TextView, TextViewState}; +use gpui_component::text::{SelectionFormat, TextView, TextViewState}; use std::cell::Cell; use std::rc::Rc; @@ -237,9 +237,14 @@ impl BlockView { // selection (e.g. `**bold**`, list markers, code fences) rather than the // rendered plain text. The hover copy button already copies full-block // source; this keeps partial-selection copy consistent. + let selection_format = if selectable { + SelectionFormat::Source + } else { + SelectionFormat::Plain + }; TextView::new(&state) .selectable(selectable) - .selectable_source(selectable) + .selection_format(selection_format) } /// Whether the copy button should currently render its "copied" checkmark. From e6f121e86c1d434b73c2e2337c70b4418060d169 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Mon, 10 Aug 2026 16:35:04 +0200 Subject: [PATCH 4/5] deps: bump gpui-component to main + pin zed to cc053a4a Consume the merged 'Copy Markdown source' work (gpui-component PR #2628) from stippi/gpui-component. Pin gpui-component to the thin pin-zed-cc053a4a branch (upstream main + one commit pinning the zed git deps) and pin the zed crates to cc053a4a so the graph resolves to a single copy of gpui. --- Cargo.lock | 145 ++++++++++++++++++++++++++++++++++++++++++----------- Cargo.toml | 35 +++++++------ 2 files changed, 136 insertions(+), 44 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index abac2918..69efd41c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1745,7 +1745,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "gpui_util", "indexmap 2.14.0", @@ -2456,7 +2456,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "proc-macro2", "quote", @@ -4444,7 +4444,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "accesskit", "anyhow", @@ -4513,6 +4513,7 @@ dependencies = [ "sum_tree", "taffy", "thiserror 2.0.18", + "tracing", "ttf-parser", "url", "usvg 0.46.0", @@ -4523,12 +4524,13 @@ dependencies = [ "windows 0.61.3", "zed-font-kit", "zed-scap", + "ztracing", ] [[package]] name = "gpui-component" version = "0.5.2" -source = "git+https://github.com/stippi/gpui-component?rev=ab37c62a6be0e9315deafa39bde1ae9f2c0894eb#ab37c62a6be0e9315deafa39bde1ae9f2c0894eb" +source = "git+https://github.com/stippi/gpui-component?rev=abeab54c#abeab54cb264742aea8ab57705109fa49389e27b" dependencies = [ "aho-corasick", "anyhow", @@ -4576,7 +4578,7 @@ dependencies = [ [[package]] name = "gpui-component-assets" version = "0.5.1" -source = "git+https://github.com/stippi/gpui-component?rev=ab37c62a6be0e9315deafa39bde1ae9f2c0894eb#ab37c62a6be0e9315deafa39bde1ae9f2c0894eb" +source = "git+https://github.com/stippi/gpui-component?rev=abeab54c#abeab54cb264742aea8ab57705109fa49389e27b" dependencies = [ "anyhow", "gpui", @@ -4590,7 +4592,7 @@ dependencies = [ [[package]] name = "gpui-component-macros" version = "0.5.1" -source = "git+https://github.com/stippi/gpui-component?rev=ab37c62a6be0e9315deafa39bde1ae9f2c0894eb#ab37c62a6be0e9315deafa39bde1ae9f2c0894eb" +source = "git+https://github.com/stippi/gpui-component?rev=abeab54c#abeab54cb264742aea8ab57705109fa49389e27b" dependencies = [ "proc-macro2", "quote", @@ -4600,7 +4602,7 @@ dependencies = [ [[package]] name = "gpui_linux" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "accesskit", "accesskit_unix", @@ -4622,6 +4624,7 @@ dependencies = [ "itertools 0.14.0", "libc", "log", + "notify-rust", "oo7", "open", "parking_lot", @@ -4651,13 +4654,14 @@ dependencies = [ [[package]] name = "gpui_macos" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "accesskit", "accesskit_macos", "anyhow", "async-task", "block", + "block2 0.6.2", "cbindgen", "cocoa 0.26.0", "collections", @@ -4685,6 +4689,7 @@ dependencies = [ "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", + "objc2-user-notifications", "parking_lot", "pathfinder_geometry", "raw-window-handle", @@ -4698,7 +4703,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -4709,7 +4714,7 @@ dependencies = [ [[package]] name = "gpui_platform" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "console_error_panic_hook", "gpui", @@ -4722,7 +4727,7 @@ dependencies = [ [[package]] name = "gpui_shared_string" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "schemars 1.2.1", "serde", @@ -4732,7 +4737,7 @@ dependencies = [ [[package]] name = "gpui_util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "anyhow", "log", @@ -4742,7 +4747,7 @@ dependencies = [ [[package]] name = "gpui_web" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "anyhow", "console_error_panic_hook", @@ -4754,7 +4759,6 @@ dependencies = [ "log", "parking_lot", "raw-window-handle", - "smallvec", "uuid", "wasm-bindgen", "wasm-bindgen-futures", @@ -4766,7 +4770,7 @@ dependencies = [ [[package]] name = "gpui_wgpu" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "anyhow", "bytemuck", @@ -4784,6 +4788,7 @@ dependencies = [ "raw-window-handle", "smallvec", "swash", + "unicode-bidi", "unicode-segmentation", "wasm-bindgen", "wasm-bindgen-futures", @@ -4795,7 +4800,7 @@ dependencies = [ [[package]] name = "gpui_windows" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "accesskit", "accesskit_windows", @@ -5154,7 +5159,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "anyhow", "async-compression", @@ -6288,6 +6293,20 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "time", + "uuid", +] + [[package]] name = "mach2" version = "0.5.0" @@ -6477,7 +6496,7 @@ checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "anyhow", "bindgen", @@ -6794,6 +6813,20 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "notify-rust" +version = "4.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" +dependencies = [ + "futures-lite 2.6.1", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus 5.15.0", +] + [[package]] name = "notify-types" version = "1.0.1" @@ -7124,6 +7157,16 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-core-text" version = "0.3.2" @@ -7255,6 +7298,19 @@ dependencies = [ "objc2-metal 0.3.2", ] +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "bitflags 2.11.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-core-location", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc_exception" version = "0.1.2" @@ -7552,7 +7608,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "collections", "serde", @@ -8630,7 +8686,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "derive_refineable", ] @@ -9184,7 +9240,7 @@ dependencies = [ [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "async-task", "backtrace", @@ -10122,7 +10178,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "heapless 0.9.3", "log", @@ -10382,9 +10438,9 @@ dependencies = [ [[package]] name = "taffy" -version = "0.12.1" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73afc801dd6bd47529eaa7c7e90557f107527d1b7c9c7ed7d7803c7b8d0c357f" +checksum = "340a09581f29809fc0df82a3955501dc7f2a21f887e5d1c13dbe288fe1c0bef4" dependencies = [ "arrayvec", "grid", @@ -10404,6 +10460,17 @@ dependencies = [ "objc", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" +dependencies = [ + "thiserror 2.0.18", + "windows 0.61.3", + "windows-version", +] + [[package]] name = "temp-env" version = "0.3.6" @@ -11517,7 +11584,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "perf", "quote", @@ -11796,8 +11863,7 @@ dependencies = [ [[package]] name = "wasm_thread" version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7516db7f32decdadb1c3b8deb1b7d78b9df7606c5cc2f6241737c2ab3a0258e" +source = "git+https://github.com/zed-industries/wasm_thread?rev=0cf96c7708dfb97ccf3da50347e25edcf75d6937#0cf96c7708dfb97ccf3da50347e25edcf75d6937" dependencies = [ "futures", "js-sys", @@ -12045,6 +12111,7 @@ dependencies = [ "thiserror 2.0.18", "wgpu-core-deps-apple", "wgpu-core-deps-emscripten", + "wgpu-core-deps-wasm", "wgpu-core-deps-windows-linux-android", "wgpu-hal", "wgpu-naga-bridge", @@ -12069,6 +12136,15 @@ dependencies = [ "wgpu-hal", ] +[[package]] +name = "wgpu-core-deps-wasm" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1fb1798be2a912497d4c224f72d39bb0cb34af50e8bcc29865bc339c943059" +dependencies = [ + "wgpu-hal", +] + [[package]] name = "wgpu-core-deps-windows-linux-android" version = "29.0.4" @@ -12677,6 +12753,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -13635,7 +13720,7 @@ checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" [[package]] name = "zlog" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "anyhow", "chrono", @@ -13692,7 +13777,7 @@ dependencies = [ [[package]] name = "ztracing" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" dependencies = [ "tracing", "tracing-subscriber", @@ -13703,7 +13788,7 @@ dependencies = [ [[package]] name = "ztracing_macro" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" +source = "git+https://github.com/zed-industries/zed?rev=cc053a4a6fa2fd0e8793201ed9099466af1be0b1#cc053a4a6fa2fd0e8793201ed9099466af1be0b1" [[package]] name = "zune-core" diff --git a/Cargo.toml b/Cargo.toml index b21b4e7e..3f25b03e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,18 +11,25 @@ resolver = "2" transmutation = { git = "https://github.com/stippi/transmutation", rev = "87f48d18ce3ff61659d7182c1b733eae8ec6f2c9" } crossterm = { git = "https://github.com/nornagon/crossterm", branch = "nornagon/color-query" } ratatui = { git = "https://github.com/nornagon/ratatui", branch = "nornagon-v0.29.0-patch" } -# gpui-component depends on gpui via an unpinned `git = ".../zed"` dependency. -# The workspace must resolve to the *same* zed commit that gpui-component -# resolves to, otherwise two copies of gpui end up in the graph and cause -# E0053/E0277/E0599 type mismatches. We pin the zed crates to the exact commit -# that the pinned gpui-component revision locks to (see gpui-component's own -# Cargo.lock). At this commit the zed workspace exposes two crates literally -# named `gpui` (a `0.0.0` internal placeholder and the real `0.2.2`), so the -# `gpui` patch must carry `version = "=0.2.2"` to disambiguate. -# When bumping gpui-component, update the `rev` to match gpui-component's -# Cargo.lock, then run `cargo update -p gpui-component -p gpui`. +# gpui-component depends on gpui via a `git = ".../zed"` dependency. On its +# upstream `main` that dependency is UNPINNED, so it floats to zed's branch +# tip. If we pinned our workspace `gpui` to a fixed `?rev=`, that would be a +# different Cargo source than gpui-component's floating one, and two copies of +# gpui would end up in the graph -> E0053/E0277/E0599 type mismatches. +# To get a single copy we consume gpui-component from the `stippi` fork on a +# thin "pin-zed" branch: it is upstream `main` plus one commit that pins the +# zed git deps (gpui/gpui_platform/gpui_web/gpui_macros/reqwest_client) to the +# exact rev below, so both gpui-component and this workspace resolve to the +# identical `git+.../zed?rev=` source. +# At this zed commit the workspace exposes two crates literally named `gpui` +# (a `0.0.0` internal placeholder and the real `0.2.2`), so the `gpui` patch +# must carry `version = "=0.2.2"` to disambiguate. +# When bumping: rebase the pin-zed commit onto the new gpui-component main, +# set the zed `rev` to whatever that main's Cargo.lock locks gpui to, push the +# branch, then update both `rev`s here and run +# `cargo update -p gpui-component -p gpui`. # CI builds with `--locked`. See .github/workflows/release.yml. -gpui = { git = "https://github.com/zed-industries/zed", rev = "1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba", version = "=0.2.2" } -gpui_platform = { git = "https://github.com/zed-industries/zed", rev = "1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" } -gpui_macros = { git = "https://github.com/zed-industries/zed", rev = "1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba" } -gpui-component = { git = "https://github.com/stippi/gpui-component", rev = "ab37c62a6be0e9315deafa39bde1ae9f2c0894eb" } +gpui = { git = "https://github.com/zed-industries/zed", rev = "cc053a4a6fa2fd0e8793201ed9099466af1be0b1", version = "=0.2.2" } +gpui_platform = { git = "https://github.com/zed-industries/zed", rev = "cc053a4a6fa2fd0e8793201ed9099466af1be0b1" } +gpui_macros = { git = "https://github.com/zed-industries/zed", rev = "cc053a4a6fa2fd0e8793201ed9099466af1be0b1" } +gpui-component = { git = "https://github.com/stippi/gpui-component", rev = "abeab54c" } From da377c19864495bd9693d91742a4b8b3785a532d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Mon, 10 Aug 2026 17:12:10 +0200 Subject: [PATCH 5/5] Fix flaky test after switching to login shells --- .../src/tools/impls/execute_command.rs | 46 +++++++++++++++---- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/crates/code_assistant_core/src/tools/impls/execute_command.rs b/crates/code_assistant_core/src/tools/impls/execute_command.rs index 5e42ce46..e110ab8e 100644 --- a/crates/code_assistant_core/src/tools/impls/execute_command.rs +++ b/crates/code_assistant_core/src/tools/impls/execute_command.rs @@ -852,6 +852,25 @@ mod tests { } } + /// Poll the mock UI's live terminal text until it contains `needle` or the + /// timeout elapses, returning the last observed text either way. Session + /// mode spawns a login shell, whose startup latency on a loaded CI runner + /// makes exact wall-clock timing of the first output unreliable. + async fn wait_for_terminal_text( + fixture: &ToolTestFixture, + needle: &str, + timeout: std::time::Duration, + ) -> String { + let deadline = tokio::time::Instant::now() + timeout; + loop { + let text = fixture.ui().unwrap().get_terminal_output_text(); + if text.contains(needle) || tokio::time::Instant::now() >= deadline { + return text; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + } + #[tokio::test(flavor = "multi_thread")] async fn session_mode_returns_session_id_while_running() -> Result<()> { let dir = tempfile::tempdir()?; @@ -970,11 +989,14 @@ mod tests { .with_tool_id("tool-bg-1".to_string()); // Short yield: the tool returns while the process is still running, - // before it prints the delayed "LATE" marker. + // before it prints the delayed "LATE" marker. The gap between the + // markers is generous so that login-shell startup latency (session + // mode spawns `$SHELL -l -c ...`) on a loaded CI runner cannot blur + // EARLY into LATE. let session_id = { let mut context = fixture.context(); let mut input = session_mode_input( - "printf 'EARLY\\n'; sleep 1; printf 'LATE\\n'; sleep 30", + "printf 'EARLY\\n'; sleep 3; printf 'LATE\\n'; sleep 30", 500, ); let result = ExecuteCommandTool.execute(&mut context, &mut input).await?; @@ -984,20 +1006,24 @@ mod tests { // The tool call is over (context dropped). The agent would now be // doing other work — no tool is polling the session. - let streamed_at_return = fixture.ui().unwrap().get_terminal_output_text(); + // The login shell may still be starting up when the tool returns, so + // don't demand EARLY at that exact instant; poll for it (comfortably + // within the 3s gap before LATE). This is the "output keeps streaming + // with no tool call polling the session" guarantee. + let streamed_early = + wait_for_terminal_text(&fixture, "EARLY", std::time::Duration::from_secs(2)).await; assert!( - streamed_at_return.contains("EARLY"), - "early output should have streamed: {streamed_at_return:?}" + streamed_early.contains("EARLY"), + "early output should have streamed: {streamed_early:?}" ); assert!( - !streamed_at_return.contains("LATE"), - "the delayed output cannot have streamed yet: {streamed_at_return:?}" + !streamed_early.contains("LATE"), + "the delayed output cannot have streamed yet: {streamed_early:?}" ); // Wait past the delay without any tool call touching the session. - tokio::time::sleep(std::time::Duration::from_millis(1500)).await; - - let streamed_later = fixture.ui().unwrap().get_terminal_output_text(); + let streamed_later = + wait_for_terminal_text(&fixture, "LATE", std::time::Duration::from_secs(5)).await; assert!( streamed_later.contains("LATE"), "output produced between turns should keep streaming to the card: {streamed_later:?}"