From 0fd576a26bfb6a4bf444d929acf6bceae0f6764f Mon Sep 17 00:00:00 2001 From: wplll <564373692@qq.com> Date: Tue, 12 May 2026 04:02:13 +0800 Subject: [PATCH] Improve prefix cache inspection and warmup --- crates/tui/src/client.rs | 202 ++++- crates/tui/src/client/chat.rs | 273 +++++-- crates/tui/src/commands/debug.rs | 969 +++++++++++++++++++++++- crates/tui/src/core/engine.rs | 24 +- crates/tui/src/core/engine/tests.rs | 85 +++ crates/tui/src/core/events.rs | 8 +- crates/tui/src/lsp/diagnostics.rs | 33 + crates/tui/src/lsp/mod.rs | 72 +- crates/tui/src/mcp.rs | 227 +++++- crates/tui/src/project_context.rs | 614 ++++++++++++++- crates/tui/src/prompts.rs | 48 +- crates/tui/src/runtime_api.rs | 15 + crates/tui/src/runtime_threads.rs | 36 + crates/tui/src/skills/mod.rs | 29 +- crates/tui/src/tools/registry.rs | 28 + crates/tui/src/tools/schema_sanitize.rs | 186 +++++ crates/tui/src/tui/app.rs | 19 +- crates/tui/src/tui/ui.rs | 98 ++- docs/MEMORY.md | 4 +- 19 files changed, 2804 insertions(+), 166 deletions(-) diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index bcae08269..7f42d0dbe 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -573,6 +573,11 @@ fn build_default_headers( } impl DeepSeekClient { + /// Returns the API base URL used by this client. + pub fn base_url(&self) -> &str { + &self.base_url + } + /// List available models from the provider. pub async fn list_models(&self) -> Result> { let url = api_url(&self.base_url, "models"); @@ -993,7 +998,7 @@ impl DeepSeekClient { mod chat; -pub(crate) use chat::PromptInspection; +pub(crate) use chat::{CacheWarmupKey, PromptInspection, PromptLayerInspection, tool_catalog_hash}; pub(crate) fn inspect_prompt_for_request(request: &MessageRequest) -> PromptInspection { chat::inspect_prompt_for_request(request) @@ -1652,6 +1657,201 @@ mod tests { )); } + #[test] + fn prompt_inspect_static_hash_ignores_diagnostics_and_tool_results() { + fn base_request(history_text: &str, tool_result: &str) -> MessageRequest { + MessageRequest { + model: "deepseek-v4-pro".to_string(), + messages: vec![ + Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: history_text.to_string(), + cache_control: None, + }], + }, + Message { + role: "assistant".to_string(), + content: vec![ContentBlock::ToolUse { + id: "tool-1".to_string(), + name: "diagnostics".to_string(), + input: json!({"path": "src/lib.rs"}), + caller: None, + }], + }, + Message { + role: "user".to_string(), + content: vec![ContentBlock::ToolResult { + tool_use_id: "tool-1".to_string(), + content: tool_result.to_string(), + is_error: None, + content_blocks: None, + }], + }, + Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "Current task".to_string(), + cache_control: None, + }], + }, + ], + max_tokens: 1024, + system: Some(SystemPrompt::Text( + "Base policy\n\n## Environment\n\n- shell: powershell\n\n## Skills\n\n- rust" + .to_string(), + )), + tools: None, + tool_choice: None, + metadata: None, + thinking: None, + reasoning_effort: Some("max".to_string()), + stream: None, + temperature: None, + top_p: None, + } + } + + let first = inspect_prompt_for_request(&base_request( + "\n ERROR [1:1] first\n", + "first diagnostic result", + )); + let second = inspect_prompt_for_request(&base_request( + "\n ERROR [2:1] second\n", + "second diagnostic result", + )); + + assert_eq!( + first.base_static_prefix_hash, second.base_static_prefix_hash, + "diagnostics and tool results are volatile context, not static prefix" + ); + } + + #[test] + fn prompt_inspect_static_hash_changes_for_tool_schema_or_project_pack() { + fn request(tool_schema: serde_json::Value, project_pack: &str) -> MessageRequest { + MessageRequest { + model: "deepseek-v4-pro".to_string(), + messages: vec![Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "Current task".to_string(), + cache_control: None, + }], + }], + max_tokens: 1024, + system: Some(SystemPrompt::Text(format!( + "Base policy\n\n## Project Context Pack\n\n\n{project_pack}\n\n\n## Environment\n\n- shell: powershell" + ))), + tools: Some(vec![Tool { + tool_type: Some("function".to_string()), + name: "read_file".to_string(), + description: "Read a file".to_string(), + input_schema: tool_schema, + allowed_callers: Some(vec!["direct".to_string()]), + defer_loading: Some(false), + input_examples: None, + strict: None, + cache_control: None, + }]), + tool_choice: None, + metadata: None, + thinking: None, + reasoning_effort: Some("max".to_string()), + stream: None, + temperature: None, + top_p: None, + } + } + + let baseline = inspect_prompt_for_request(&request( + json!({"type": "object", "properties": {"path": {"type": "string"}}}), + "{\"files\":[\"src/lib.rs\"]}", + )); + let changed_tool = inspect_prompt_for_request(&request( + json!({"type": "object", "properties": {"path": {"type": "string"}, "offset": {"type": "integer"}}}), + "{\"files\":[\"src/lib.rs\"]}", + )); + let changed_pack = inspect_prompt_for_request(&request( + json!({"type": "object", "properties": {"path": {"type": "string"}}}), + "{\"files\":[\"src/main.rs\"]}", + )); + + assert_ne!( + baseline.base_static_prefix_hash, changed_tool.base_static_prefix_hash, + "tool schema is part of the stable API prefix" + ); + assert_ne!( + baseline.base_static_prefix_hash, changed_pack.base_static_prefix_hash, + "project pack is part of the stable system prefix" + ); + assert!( + baseline.layers.iter().any(|layer| { + layer.name == "Tool schema" && layer.stability.label() == "static" + }) + ); + } + + #[test] + fn prompt_inspect_is_stable_for_identical_request_input() { + let request = MessageRequest { + model: "deepseek-v4-pro".to_string(), + messages: vec![ + Message { + role: "assistant".to_string(), + content: vec![ContentBlock::Text { + text: "Stable prior answer".to_string(), + cache_control: None, + }], + }, + Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "Current task".to_string(), + cache_control: None, + }], + }, + ], + max_tokens: 1024, + system: Some(SystemPrompt::Text( + "Base policy\n\n## Environment\n\n- shell: powershell\n\n## Skills\n\n- rust" + .to_string(), + )), + tools: None, + tool_choice: None, + metadata: None, + thinking: None, + reasoning_effort: Some("max".to_string()), + stream: None, + temperature: None, + top_p: None, + }; + + let first = inspect_prompt_for_request(&request); + let second = inspect_prompt_for_request(&request); + + assert_eq!( + first.base_static_prefix_hash, second.base_static_prefix_hash, + "identical prompt input must produce the same static prefix hash" + ); + assert_eq!( + first.full_request_prefix_hash, second.full_request_prefix_hash, + "identical prompt input must produce the same full reusable prefix hash" + ); + assert_eq!( + first + .layers + .iter() + .map(|layer| &layer.sha256) + .collect::>(), + second + .layers + .iter() + .map(|layer| &layer.sha256) + .collect::>() + ); + } + #[test] fn cache_warmup_request_reuses_stable_prefix_and_fixed_user_tail() { let request = MessageRequest { diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index 62c2b63b3..eae4aec34 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -9,6 +9,7 @@ use std::pin::Pin; use std::time::Duration; use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use tokio::time::timeout as tokio_timeout; @@ -420,6 +421,13 @@ pub(crate) fn inspect_prompt_for_request(request: &MessageRequest) -> PromptInsp PromptBuilder::for_request(request).inspect() } +pub(crate) fn tool_catalog_hash(tools: &[Tool]) -> String { + let values = tools.iter().map(tool_to_chat).collect::>(); + let content = + serde_json::to_string(&values).unwrap_or_else(|_| Value::Array(values).to_string()); + sha256_hex(content.as_bytes()) +} + pub(crate) fn build_cache_warmup_request(request: &MessageRequest) -> MessageRequest { PromptBuilder::for_request(request).build_cache_warmup_request() } @@ -427,6 +435,7 @@ pub(crate) fn build_cache_warmup_request(request: &MessageRequest) -> MessageReq struct PromptBuilder<'a> { system: Option<&'a SystemPrompt>, messages: &'a [Message], + tools: Option<&'a [Tool]>, model: &'a str, reasoning_effort: Option<&'a str>, } @@ -436,6 +445,7 @@ impl<'a> PromptBuilder<'a> { Self { system: request.system.as_ref(), messages: &request.messages, + tools: request.tools.as_deref(), model: &request.model, reasoning_effort: request.reasoning_effort.as_deref(), } @@ -459,7 +469,13 @@ impl<'a> PromptBuilder<'a> { should_replay_reasoning_content(self.model, self.reasoning_effort), true, ); - inspect_wire_messages(&messages) + let tools = self.tools.map(|tools| { + tools + .iter() + .map(tool_to_chat) + .collect::>() + }); + inspect_wire_messages(&messages, tools.as_deref()) } fn build_cache_warmup_request(self) -> MessageRequest { @@ -495,24 +511,106 @@ const TOOL_RESULT_SENT_CHAR_BUDGET: usize = 12_000; const TOOL_RESULT_HEAD_CHARS: usize = 4_000; const TOOL_RESULT_TAIL_CHARS: usize = 4_000; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub(crate) struct PromptInspection { pub base_static_prefix_hash: String, pub full_request_prefix_hash: String, + /// Hash of the tool schema JSON (empty string when no tools registered). + pub tool_catalog_hash: String, + /// Hash of static layers excluding tool schema — used for warmup key. + pub stable_prefix_hash: String, pub layers: Vec, } -#[derive(Debug, Clone, PartialEq, Eq)] +/// Identifies the stable prefix that a cache warmup primes. +/// +/// Two warmup keys are equal when the warmup request would produce +/// byte-identical stable prefixes — meaning a provider cache hit from +/// the first warmup is still valid for the second. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct CacheWarmupKey { + pub provider: String, + pub model: String, + pub base_url: String, + /// Hash of static system prompt layers (excluding tool schema). + pub static_prefix_hash: String, + /// Hash of the tool catalog JSON (empty if no tools). + pub tool_catalog_hash: String, + /// Hash of the Project Context Pack layer (empty when absent). + pub project_pack_hash: String, + /// Hash of the Skills layer (empty when absent). + pub skills_hash: String, +} + +impl CacheWarmupKey { + pub(crate) fn new( + provider: &str, + model: &str, + base_url: &str, + static_prefix_hash: String, + tool_catalog_hash: String, + project_pack_hash: String, + skills_hash: String, + ) -> Self { + Self { + provider: provider.to_string(), + model: model.to_string(), + base_url: base_url.to_string(), + static_prefix_hash, + tool_catalog_hash, + project_pack_hash, + skills_hash, + } + } + + pub(crate) fn from_inspection( + provider: &str, + model: &str, + base_url: &str, + inspection: &PromptInspection, + ) -> Self { + Self::new( + provider, + model, + base_url, + inspection.stable_prefix_hash.clone(), + inspection.tool_catalog_hash.clone(), + layer_hash(inspection, "Project context pack"), + layer_hash(inspection, "Skills"), + ) + } + + /// Returns a short hex prefix for display (first 12 chars of the hash). + pub(crate) fn hash_short(&self) -> String { + let json = serde_json::to_string(self).unwrap_or_default(); + let hash = sha256_hex(json.as_bytes()); + hash[..hash.len().min(12)].to_string() + } +} + +fn layer_hash(inspection: &PromptInspection, name: &str) -> String { + inspection + .layers + .iter() + .find(|layer| layer.name == name) + .map(|layer| layer.sha256.clone()) + .unwrap_or_default() +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub(crate) struct PromptLayerInspection { pub name: String, pub stability: PromptLayerStability, pub char_len: usize, + pub byte_len: usize, + /// Rough token estimate (chars / 4, minimum 1 for non-empty layers). + pub token_estimate: usize, pub sha256: String, pub tool_result: Option, pub turn_meta: Option, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub(crate) struct ToolResultInspection { pub original_chars: usize, pub sent_chars: usize, @@ -520,7 +618,7 @@ pub(crate) struct ToolResultInspection { pub deduplicated: bool, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub(crate) struct TurnMetaInspection { pub original_chars: usize, pub sent_chars: usize, @@ -528,7 +626,7 @@ pub(crate) struct TurnMetaInspection { pub sha256: String, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub(crate) enum PromptLayerStability { Static, History, @@ -545,56 +643,91 @@ impl PromptLayerStability { } } -fn inspect_wire_messages(messages: &[Value]) -> PromptInspection { +fn inspect_wire_messages(messages: &[Value], tools: Option<&[Value]>) -> PromptInspection { let mut layers = Vec::new(); let mut base_static_prefix_parts = Vec::new(); + let mut stable_prefix_parts = Vec::new(); // excludes tool schema let mut full_request_prefix_parts = Vec::new(); + let mut tool_catalog_hash = String::new(); - for (index, message) in messages.iter().enumerate() { + let mut message_start = 0usize; + if let Some(message) = messages.first() { let role = message .get("role") .and_then(Value::as_str) .unwrap_or("unknown"); - let content = message_content_for_inspect(message); - let is_last = index + 1 == messages.len(); - - if index == 0 && role == "system" { + if role == "system" { + let content = message_content_for_inspect(message); for (name, stability, body) in split_system_layers(&content) { if stability == PromptLayerStability::Static { base_static_prefix_parts.push(body.to_string()); + stable_prefix_parts.push(body.to_string()); } if stability != PromptLayerStability::Dynamic { full_request_prefix_parts.push(body.to_string()); } layers.push(prompt_layer(name, stability, body)); } + message_start = 1; + } + } + + if let Some(tools) = tools + && !tools.is_empty() + { + let content = serde_json::to_string(tools) + .unwrap_or_else(|_| Value::Array(tools.to_vec()).to_string()); + tool_catalog_hash = sha256_hex(content.as_bytes()); + base_static_prefix_parts.push(content.clone()); + full_request_prefix_parts.push(content.clone()); + // Note: stable_prefix_parts intentionally excludes tool schema — + // the warmup request does not include tools. + layers.push(prompt_layer( + "Tool schema".to_string(), + PromptLayerStability::Static, + &content, + )); + } + + for (index, message) in messages.iter().enumerate() { + if index < message_start { + continue; + } + let role = message + .get("role") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let content = message_content_for_inspect(message); + let is_last = index + 1 == messages.len(); + + let stability = if (is_last && role == "user") || role == "tool" { + PromptLayerStability::Dynamic } else { - let stability = if (is_last && role == "user") || role == "tool" { - PromptLayerStability::Dynamic - } else { - PromptLayerStability::History - }; - let name = if is_last && role == "user" { - "User task".to_string() - } else { - format!("Message #{index} {role}") - }; - if stability != PromptLayerStability::Dynamic { - full_request_prefix_parts.push(content.clone()); - } - let mut layer = prompt_layer(name, stability, &content); - layer.tool_result = tool_result_inspection_for_message(message); - layer.turn_meta = turn_meta_inspection_for_message(message); - layers.push(layer); + PromptLayerStability::History + }; + let name = if is_last && role == "user" { + "User task".to_string() + } else { + format!("Message #{index} {role}") + }; + if stability != PromptLayerStability::Dynamic { + full_request_prefix_parts.push(content.clone()); } + let mut layer = prompt_layer(name, stability, &content); + layer.tool_result = tool_result_inspection_for_message(message); + layer.turn_meta = turn_meta_inspection_for_message(message); + layers.push(layer); } let base_static_prefix = base_static_prefix_parts.join("\n"); + let stable_prefix = stable_prefix_parts.join("\n"); let full_request_prefix = full_request_prefix_parts.join("\n"); PromptInspection { base_static_prefix_hash: sha256_hex(base_static_prefix.as_bytes()), full_request_prefix_hash: sha256_hex(full_request_prefix.as_bytes()), + tool_catalog_hash, + stable_prefix_hash: sha256_hex(stable_prefix.as_bytes()), layers, } } @@ -696,7 +829,10 @@ fn split_system_layers(content: &str) -> Vec<(String, PromptLayerStability, &str for (i, (start, name)) in starts.iter().enumerate() { let end = starts.get(i + 1).map_or(content.len(), |(idx, _)| *idx); - let stability = if *name == "Previous session handoff" { + let stability = if matches!( + *name, + "User memory" | "Current session goal" | "Previous session handoff" + ) { PromptLayerStability::Dynamic } else if is_static_base_layer(name) { PromptLayerStability::Static @@ -729,7 +865,7 @@ fn is_static_base_layer(name: &str) -> bool { ) } -fn stable_system_prompt(system: Option<&SystemPrompt>) -> Option { +pub(crate) fn stable_system_prompt(system: Option<&SystemPrompt>) -> Option { let instructions = system_to_instructions(system.cloned())?; let stable = split_system_layers(&instructions) .into_iter() @@ -761,17 +897,26 @@ fn prompt_layer( stability: PromptLayerStability, content: &str, ) -> PromptLayerInspection { + let char_len = content.chars().count(); + let byte_len = content.len(); + let token_estimate = if char_len == 0 { + 0 + } else { + (char_len / 4).max(1) + }; PromptLayerInspection { name, stability, - char_len: content.chars().count(), + char_len, + byte_len, + token_estimate, sha256: sha256_hex(content.as_bytes()), tool_result: None, turn_meta: None, } } -fn sha256_hex(bytes: &[u8]) -> String { +pub(crate) fn sha256_hex(bytes: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(bytes); format!("{:x}", hasher.finalize()) @@ -865,6 +1010,19 @@ fn compact_tool_result_for_wire( seen_tool_results: &mut HashMap, ) -> WireToolResult { let original_chars = content.chars().count(); + + // Short content: skip dedup entirely — not worth the ref overhead. + // Do NOT compute hash or insert into seen_tool_results. + if original_chars <= TOOL_RESULT_SENT_CHAR_BUDGET { + return WireToolResult { + content: content.to_string(), + original_chars, + sent_chars: original_chars, + truncated: false, + deduplicated: false, + }; + } + let sha = sha256_hex(content.as_bytes()); if let Some(previous) = seen_tool_results.get(&sha) { @@ -889,16 +1047,6 @@ fn compact_tool_result_for_wire( }, ); - if original_chars <= TOOL_RESULT_SENT_CHAR_BUDGET { - return WireToolResult { - content: content.to_string(), - original_chars, - sent_chars: original_chars, - truncated: false, - deduplicated: false, - }; - } - let head = first_chars(content, TOOL_RESULT_HEAD_CHARS); let tail = last_chars(content, TOOL_RESULT_TAIL_CHARS); let kept = head.chars().count() + tail.chars().count(); @@ -2528,19 +2676,26 @@ mod stream_decoder_tests { #[test] fn request_builder_deduplicates_identical_tool_results_for_wire() { - let output = "same tool output"; + // Use content >12,000 chars so dedup is triggered. + let long_output = "X".repeat(13_000); let messages = vec![ tool_use_message("tool-1", "read_file", json!({"path": "README.md"})), - tool_result_message("tool-1", output), + tool_result_message("tool-1", &long_output), tool_use_message("tool-2", "read_file", json!({"path": "README.md"})), - tool_result_message("tool-2", output), + tool_result_message("tool-2", &long_output), ]; let built = build_chat_messages(None, &messages, "deepseek-v4-flash"); let first = tool_message_content(&built, 0); let second = tool_message_content(&built, 1); - assert_eq!(first, output); + // First is truncated (13k > 12k budget) but not deduplicated. + assert!(first.contains("TOOL_RESULT_TRUNCATED"), "got: {first}"); + assert!( + !first.contains("TOOL_RESULT_REF"), + "first must not be a ref" + ); + // Second is deduplicated via ref. assert!( second.starts_with(" CommandResult { /// Renders a fixed-width table the user can paste into a bug report. pub fn cache(app: &mut App, arg: Option<&str>) -> CommandResult { let arg = arg.map(str::trim).filter(|s| !s.is_empty()); - if matches!(arg, Some("inspect")) { - return CommandResult::message(format_cache_inspect(app)); + if let Some(inspect_arg) = arg.and_then(|a| a.strip_prefix("inspect")) { + let flags = inspect_arg.trim(); + let verbose = flags.contains("--verbose"); + let json_mode = flags.contains("--json"); + return CommandResult::message(format_cache_inspect(app, verbose, json_mode)); } if matches!(arg, Some("warmup")) { return CommandResult::action(AppAction::CacheWarmup); @@ -158,7 +163,7 @@ pub fn cache(app: &mut App, arg: Option<&str>) -> CommandResult { CommandResult::message(format_cache_history(app, count, app.ui_locale)) } -fn format_cache_inspect(app: &mut App) -> String { +fn format_cache_inspect(app: &mut App, verbose: bool, json_mode: bool) -> String { let reasoning_effort = if app.reasoning_effort == crate::tui::app::ReasoningEffort::Auto { app.last_effective_reasoning_effort .and_then(crate::tui::app::ReasoningEffort::api_value) @@ -171,7 +176,7 @@ fn format_cache_inspect(app: &mut App) -> String { messages: app.api_messages.clone(), max_tokens: 0, system: app.system_prompt.clone(), - tools: None, + tools: app.session.last_tool_catalog.clone(), tool_choice: None, metadata: None, thinking: None, @@ -181,8 +186,23 @@ fn format_cache_inspect(app: &mut App) -> String { top_p: None, }; let inspection = inspect_prompt_for_request(&request); + let previous = app.session.last_cache_inspection.as_ref(); + // Compute warmup key from the current inspection. + let provider_str = format!("{:?}", app.api_provider); + let base_url = app.session.last_base_url.clone().unwrap_or_default(); + let current_warmup_key = + CacheWarmupKey::from_inspection(&provider_str, &app.model, &base_url, &inspection); + + if json_mode { + let json = serde_json::to_string_pretty(&inspection) + .unwrap_or_else(|_| "{\"error\": \"serialization failed\"}".to_string()); + app.session.last_cache_inspection = Some(inspection); + app.session.last_warmup_key = Some(current_warmup_key); + return json; + } + let mut out = String::new(); out.push_str("Cache Inspect\n"); out.push_str("Full prompt text is not printed. Hashes are SHA-256 of each rendered layer.\n"); @@ -194,22 +214,40 @@ fn format_cache_inspect(app: &mut App) -> String { "Full request prefix hash: {}\n", inspection.full_request_prefix_hash )); + out.push_str(&format!( + "Tool catalog hash: {}\n", + if inspection.tool_catalog_hash.is_empty() { + "(no tools registered)".to_string() + } else { + inspection.tool_catalog_hash.clone() + } + )); + out.push_str(&format!( + "Stable prefix hash (excl. tools): {}\n", + inspection.stable_prefix_hash + )); out.push_str(&format_static_prefix_status(previous, &inspection)); out.push_str(&format_first_divergence(previous, &inspection)); out.push('\n'); + // Estimate total tokens across all layers. + let total_tokens: usize = inspection.layers.iter().map(|l| l.token_estimate).sum(); + out.push_str(&format!("Estimated total tokens: ~{total_tokens}\n\n")); + for layer in &inspection.layers { let mut line = format!( - "{}: {}, chars={}, hash={}\n", + "{}: {}, chars={}, bytes={}, ~{}tok, hash={}\n", layer.name, layer.stability.label(), layer.char_len, + layer.byte_len, + layer.token_estimate, layer.sha256 ); if let Some(tool_result) = &layer.tool_result { let trimmed = line.trim_end_matches('\n').to_string(); line = format!( - "{trimmed}, original_chars={}, sent_chars={}, truncated={}, deduplicated={}\n", + "{trimmed}, orig_chars={}, sent_chars={}, truncated={}, dedup={}\n", tool_result.original_chars, tool_result.sent_chars, tool_result.truncated, @@ -219,7 +257,7 @@ fn format_cache_inspect(app: &mut App) -> String { if let Some(turn_meta) = &layer.turn_meta { let trimmed = line.trim_end_matches('\n').to_string(); line = format!( - "{trimmed}, turn_meta_original_chars={}, turn_meta_sent_chars={}, turn_meta_deduplicated={}, turn_meta_sha256={}\n", + "{trimmed}, meta_orig={}, meta_sent={}, meta_dedup={}, meta_hash={}\n", turn_meta.original_chars, turn_meta.sent_chars, turn_meta.deduplicated, @@ -228,10 +266,66 @@ fn format_cache_inspect(app: &mut App) -> String { } out.push_str(&line); } + + // Verbose mode: show layer-by-layer diff with previous inspection. + if verbose { + out.push_str("\n── Verbose diff ──\n"); + if let Some(prev) = previous { + out.push_str(&format_verbose_diff(prev, &inspection)); + } else { + out.push_str("No previous inspection to compare against.\n"); + } + } + + // Warmup status. + out.push('\n'); + out.push_str(&format_warmup_status( + app.session.last_warmup_key.as_ref(), + ¤t_warmup_key, + )); + app.session.last_cache_inspection = Some(inspection); + app.session.last_warmup_key = Some(current_warmup_key); out } +fn format_warmup_status(last_warmup: Option<&CacheWarmupKey>, current: &CacheWarmupKey) -> String { + match last_warmup { + None => format!( + "Warmup status: no previous warmup (current key: {}…)\n", + current.hash_short() + ), + Some(prev) if prev == current => format!( + "Warmup status: valid (key {}… matches)\n", + current.hash_short() + ), + Some(prev) => { + let mut reasons = Vec::new(); + if prev.provider != current.provider { + reasons.push(format!("provider {}→{}", prev.provider, current.provider)); + } + if prev.model != current.model { + reasons.push(format!("model {}→{}", prev.model, current.model)); + } + if prev.base_url != current.base_url { + reasons.push(format!("base_url {}→{}", prev.base_url, current.base_url)); + } + if prev.static_prefix_hash != current.static_prefix_hash { + reasons.push("static prefix changed".to_string()); + } + if prev.tool_catalog_hash != current.tool_catalog_hash { + reasons.push("tool catalog changed".to_string()); + } + format!( + "Warmup status: INVALID (key {}… → {}…, {})\n", + prev.hash_short(), + current.hash_short(), + reasons.join(", ") + ) + } + } +} + fn format_static_prefix_status( previous: Option<&PromptInspection>, current: &PromptInspection, @@ -266,7 +360,11 @@ fn format_first_divergence( match (previous.layers.get(index), current.layers.get(index)) { (Some(prev), Some(curr)) if prev.name == curr.name && prev.sha256 == curr.sha256 => {} (Some(prev), Some(curr)) if prev.name == curr.name => { - return format!("First divergence from previous request: {}\n", curr.name); + let cause = infer_divergence_cause(&curr.name, prev, curr); + return format!( + "First divergence from previous request: {}{}\n", + curr.name, cause + ); } (Some(_), Some(curr)) => { return format!("First divergence from previous request: {}\n", curr.name); @@ -286,6 +384,153 @@ fn format_first_divergence( "First divergence from previous request: none\n".to_string() } +/// Infer a human-readable cause for why a layer diverged from the previous request. +fn infer_divergence_cause( + layer_name: &str, + prev: &PromptLayerInspection, + curr: &PromptLayerInspection, +) -> String { + // Size-based hints for common divergence patterns. + let char_delta = curr.char_len as i64 - prev.char_len as i64; + + let hint = match layer_name { + "Tool schema" => { + if char_delta > 0 { + " (possible cause: tool added or schema expanded)" + } else if char_delta < 0 { + " (possible cause: tool removed or schema shrunk)" + } else { + " (possible cause: tool order or schema content changed)" + } + } + "Project context pack" => { + if char_delta.abs() > 100 { + " (possible cause: files added/removed from workspace)" + } else { + " (possible cause: file content or structure changed)" + } + } + "Project context" => " (possible cause: CLAUDE.md or AGENTS.md changed)", + "Skills" => { + if char_delta > 0 { + " (possible cause: new skill installed)" + } else if char_delta < 0 { + " (possible cause: skill removed)" + } else { + " (possible cause: skill content changed)" + } + } + "Environment" => " (possible cause: environment info updated)", + "Configured instructions" => " (possible cause: instructions file changed)", + "Global system prefix" => " (possible cause: mode prompt or base policy changed)", + "User memory" => " (possible cause: memory edited via /memory or # quick-add)", + "Current session goal" => " (possible cause: goal updated)", + "Previous session handoff" => " (possible cause: compaction rewrote handoff)", + "Context management" | "Compact template" => " (unexpected: compile-time constant changed)", + name if name.starts_with("Message #") => { + if char_delta.abs() > 500 { + " (possible cause: large tool result or message change)" + } else { + " (possible cause: message content changed)" + } + } + "User task" => " (expected: new user message each turn)", + _ => "", + }; + + if hint.is_empty() && curr.tool_result.as_ref().is_some_and(|t| t.deduplicated) { + " (tool result deduplicated)".to_string() + } else if hint.is_empty() && curr.turn_meta.as_ref().is_some_and(|t| t.deduplicated) { + " (turn meta deduplicated)".to_string() + } else { + hint.to_string() + } +} + +/// Compare layer metadata between two inspections and produce a human-readable diff. +/// Does NOT print full prompt text — only names, hashes, sizes, and stability labels. +fn format_verbose_diff(prev: &PromptInspection, curr: &PromptInspection) -> String { + let mut out = String::new(); + let max_len = prev.layers.len().max(curr.layers.len()); + + for index in 0..max_len { + match (prev.layers.get(index), curr.layers.get(index)) { + (Some(p), Some(c)) if p == c => { + out.push_str(&format!(" [{}] {} — unchanged\n", index, c.name)); + } + (Some(p), Some(c)) => { + out.push_str(&format!(" [{}] {} — CHANGED\n", index, c.name)); + if p.name != c.name { + out.push_str(&format!(" name: {} → {}\n", p.name, c.name)); + } + if p.sha256 != c.sha256 { + out.push_str(&format!( + " hash: {}…{} → {}…{}\n", + &p.sha256[..8], + &p.sha256[p.sha256.len() - 8..], + &c.sha256[..8], + &c.sha256[c.sha256.len() - 8..], + )); + } + if p.stability != c.stability { + out.push_str(&format!( + " stability: {} → {}\n", + p.stability.label(), + c.stability.label() + )); + } + if p.char_len != c.char_len { + out.push_str(&format!( + " chars: {} → {} ({:+})\n", + p.char_len, + c.char_len, + c.char_len as i64 - p.char_len as i64 + )); + } + if p.byte_len != c.byte_len { + out.push_str(&format!( + " bytes: {} → {} ({:+})\n", + p.byte_len, + c.byte_len, + c.byte_len as i64 - p.byte_len as i64 + )); + } + if p.token_estimate != c.token_estimate { + out.push_str(&format!( + " tokens: ~{} → ~{} ({:+})\n", + p.token_estimate, + c.token_estimate, + c.token_estimate as i64 - p.token_estimate as i64 + )); + } + let cause = infer_divergence_cause(&c.name, p, c); + if !cause.is_empty() { + out.push_str(&format!(" cause:{}\n", cause)); + } + } + (Some(p), None) => { + out.push_str(&format!( + " [{}] {} — REMOVED (was {} chars, {} bytes, ~{}tok)\n", + index, p.name, p.char_len, p.byte_len, p.token_estimate + )); + } + (None, Some(c)) => { + out.push_str(&format!( + " [{}] {} — ADDED ({} chars, {} bytes, ~{}tok, {})\n", + index, + c.name, + c.char_len, + c.byte_len, + c.token_estimate, + c.stability.label() + )); + } + (None, None) => break, + } + } + out +} + fn changed_static_layers(previous: &PromptInspection, current: &PromptInspection) -> Vec { current .layers @@ -421,7 +666,7 @@ fn humanize_age(d: std::time::Duration) -> String { mod tests { use super::*; use crate::config::Config; - use crate::models::{ContentBlock, Message, SystemBlock}; + use crate::models::{ContentBlock, Message, SystemBlock, Tool}; use crate::tui::app::{App, TuiOptions}; use crate::tui::history::{GenericToolCell, ToolCell, ToolStatus}; use std::path::PathBuf; @@ -454,6 +699,20 @@ mod tests { app } + fn test_tool(name: &str) -> Tool { + Tool { + tool_type: Some("function".to_string()), + name: name.to_string(), + description: format!("Test tool {name}"), + input_schema: serde_json::json!({"type": "object"}), + allowed_callers: Some(vec!["direct".to_string()]), + defer_loading: None, + input_examples: None, + strict: None, + cache_control: None, + } + } + #[test] fn test_tokens_shows_usage_info() { let mut app = create_test_app(); @@ -681,10 +940,10 @@ mod tests { let result = cache(&mut app, Some("inspect")); let msg = result.message.expect("inspect output"); - assert!(msg.contains("original_chars=14000"), "got: {msg}"); + assert!(msg.contains("orig_chars=14000"), "got: {msg}"); assert!(msg.contains("truncated=true"), "got: {msg}"); - assert!(msg.contains("deduplicated=false"), "got: {msg}"); - assert!(msg.contains("deduplicated=true"), "got: {msg}"); + assert!(msg.contains("dedup=false"), "got: {msg}"); + assert!(msg.contains("dedup=true"), "got: {msg}"); } #[test] @@ -724,11 +983,11 @@ mod tests { let result = cache(&mut app, Some("inspect")); let msg = result.message.expect("inspect output"); - assert!(msg.contains("turn_meta_original_chars="), "got: {msg}"); - assert!(msg.contains("turn_meta_sent_chars="), "got: {msg}"); - assert!(msg.contains("turn_meta_deduplicated=false"), "got: {msg}"); - assert!(msg.contains("turn_meta_deduplicated=true"), "got: {msg}"); - assert!(msg.contains("turn_meta_sha256="), "got: {msg}"); + assert!(msg.contains("meta_orig="), "got: {msg}"); + assert!(msg.contains("meta_sent="), "got: {msg}"); + assert!(msg.contains("meta_dedup=false"), "got: {msg}"); + assert!(msg.contains("meta_dedup=true"), "got: {msg}"); + assert!(msg.contains("meta_hash="), "got: {msg}"); assert!(!msg.contains("Working set: src/lib.rs"), "got: {msg}"); } @@ -1393,6 +1652,680 @@ mod tests { ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == "call-a" )); } + + #[test] + fn cache_inspect_layer_hashes_are_stable_across_calls() { + let mut app = create_test_app(); + app.system_prompt = Some(SystemPrompt::Text( + "Base policy\n\n## Environment\n\n- shell: powershell".to_string(), + )); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "stable task".to_string(), + cache_control: None, + }], + }); + + let first = cache(&mut app, Some("inspect")) + .message + .expect("first inspect"); + // Clear last_cache_inspection so we rebuild from scratch. + app.session.last_cache_inspection = None; + let second = cache(&mut app, Some("inspect")) + .message + .expect("second inspect"); + + // Extract hashes from both outputs and compare. + let extract_hashes = |s: &str| -> Vec { + s.lines() + .filter(|l| l.contains("hash=")) + .filter_map(|l| l.split("hash=").nth(1).map(str::to_string)) + .collect() + }; + let h1 = extract_hashes(&first); + let h2 = extract_hashes(&second); + assert_eq!(h1, h2, "hashes must be stable across identical calls"); + } + + #[test] + fn cache_inspect_json_mode_roundtrips() { + let mut app = create_test_app(); + app.system_prompt = Some(SystemPrompt::Text("Base policy".to_string())); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "json test".to_string(), + cache_control: None, + }], + }); + + let result = cache(&mut app, Some("inspect --json")); + let msg = result.message.expect("json output"); + + // Must be valid JSON. + let parsed: serde_json::Value = + serde_json::from_str(&msg).expect("output must be valid JSON"); + + // Must contain the expected top-level keys. + assert!(parsed.get("base_static_prefix_hash").is_some()); + assert!(parsed.get("full_request_prefix_hash").is_some()); + assert!(parsed.get("layers").is_some()); + let layers = parsed["layers"].as_array().expect("layers is array"); + assert!(!layers.is_empty()); + + // Each layer must have byte_len and token_estimate. + for layer in layers { + assert!(layer.get("byte_len").is_some()); + assert!(layer.get("token_estimate").is_some()); + assert!(layer.get("char_len").is_some()); + assert!(layer.get("sha256").is_some()); + assert!(layer.get("name").is_some()); + assert!(layer.get("stability").is_some()); + } + } + + #[test] + fn cache_inspect_verbose_shows_diff_on_change() { + let mut app = create_test_app(); + app.system_prompt = Some(SystemPrompt::Text( + "Base policy\n\n## Environment\n\n- shell: powershell".to_string(), + )); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "first task".to_string(), + cache_control: None, + }], + }); + + // First call — no previous inspection to diff against. + let first = cache(&mut app, Some("inspect --verbose")) + .message + .expect("first verbose"); + assert!( + first.contains("No previous inspection to compare against"), + "got: {first}" + ); + + // Change the user message. + if let Some(last) = app.api_messages.last_mut() + && let Some(ContentBlock::Text { text, .. }) = last.content.first_mut() + { + *text = "second task".to_string(); + } + + // Second call — should show a diff. + let second = cache(&mut app, Some("inspect --verbose")) + .message + .expect("second verbose"); + assert!(second.contains("── Verbose diff ──"), "got: {second}"); + assert!( + second.contains("CHANGED") || second.contains("ADDED") || second.contains("REMOVED"), + "expected at least one diff marker, got: {second}" + ); + } + + #[test] + fn cache_inspect_default_mode_has_no_diff_output() { + let mut app = create_test_app(); + app.system_prompt = Some(SystemPrompt::Text("Base policy".to_string())); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "task".to_string(), + cache_control: None, + }], + }); + + let _ = cache(&mut app, Some("inspect")); + // Change message. + if let Some(last) = app.api_messages.last_mut() + && let Some(ContentBlock::Text { text, .. }) = last.content.first_mut() + { + *text = "task changed".to_string(); + } + let second = cache(&mut app, Some("inspect")) + .message + .expect("second default"); + + // Default mode must NOT contain verbose diff markers. + assert!( + !second.contains("── Verbose diff ──"), + "default mode should not show verbose diff" + ); + assert!( + !second.contains("CHANGED"), + "default mode should not show CHANGED markers" + ); + } + + #[test] + fn cache_inspect_divergence_cause_inference() { + let mut app = create_test_app(); + app.system_prompt = Some(SystemPrompt::Text( + "Base policy\n\n## Environment\n\n- shell: powershell".to_string(), + )); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "first task".to_string(), + cache_control: None, + }], + }); + + let _ = cache(&mut app, Some("inspect")); + // Change user message — should trigger cause inference. + if let Some(last) = app.api_messages.last_mut() + && let Some(ContentBlock::Text { text, .. }) = last.content.first_mut() + { + *text = "second task".to_string(); + } + let second = cache(&mut app, Some("inspect")) + .message + .expect("second inspect"); + + // "User task" layer should show expected cause. + assert!( + second.contains("(expected: new user message each turn)"), + "got: {second}" + ); + } + + #[test] + fn cache_inspect_byte_len_and_token_estimate_present() { + let mut app = create_test_app(); + app.system_prompt = Some(SystemPrompt::Text("Base policy".to_string())); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "test".to_string(), + cache_control: None, + }], + }); + + let result = cache(&mut app, Some("inspect")) + .message + .expect("inspect output"); + + // Every layer line must show bytes= and ~Ntok. + for line in result.lines() { + if line.contains("chars=") { + assert!(line.contains("bytes="), "missing bytes in: {line}"); + assert!( + line.contains("tok,") || line.contains("tok\n") || line.ends_with("tok"), + "missing token_estimate in: {line}" + ); + } + } + // Must show estimated total tokens. + assert!(result.contains("Estimated total tokens:"), "got: {result}"); + } + + // ── Phase 7: CacheWarmupKey tests ── + + #[test] + fn warmup_key_is_stable_across_repeated_inspect() { + let mut app = create_test_app(); + app.system_prompt = Some(SystemPrompt::Text("Base policy".to_string())); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "task".to_string(), + cache_control: None, + }], + }); + + let _ = cache(&mut app, Some("inspect")); + let key1 = app.session.last_warmup_key.clone().expect("key1"); + + // Run inspect again with the same state. + let _ = cache(&mut app, Some("inspect")); + let key2 = app.session.last_warmup_key.clone().expect("key2"); + + assert_eq!( + key1, key2, + "warmup key must be stable across repeated inspect" + ); + } + + #[test] + fn warmup_key_changes_when_model_changes() { + let mut app = create_test_app(); + app.system_prompt = Some(SystemPrompt::Text("Base policy".to_string())); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "task".to_string(), + cache_control: None, + }], + }); + + let _ = cache(&mut app, Some("inspect")); + let key1 = app.session.last_warmup_key.clone().expect("key1"); + + app.model = "deepseek-v3".to_string(); + let _ = cache(&mut app, Some("inspect")); + let key2 = app.session.last_warmup_key.clone().expect("key2"); + + assert_ne!(key1, key2, "warmup key must change when model changes"); + assert_eq!(key1.provider, key2.provider); + assert_ne!(key1.model, key2.model); + } + + #[test] + fn warmup_key_changes_when_provider_changes() { + let mut app = create_test_app(); + app.system_prompt = Some(SystemPrompt::Text("Base policy".to_string())); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "task".to_string(), + cache_control: None, + }], + }); + + let _ = cache(&mut app, Some("inspect")); + let key1 = app.session.last_warmup_key.clone().expect("key1"); + + app.api_provider = crate::config::ApiProvider::DeepseekCN; + let _ = cache(&mut app, Some("inspect")); + let key2 = app.session.last_warmup_key.clone().expect("key2"); + + assert_ne!(key1, key2, "warmup key must change when provider changes"); + assert_ne!(key1.provider, key2.provider); + } + + #[test] + fn warmup_key_changes_when_static_prefix_changes() { + let mut app = create_test_app(); + app.system_prompt = Some(SystemPrompt::Text("Base policy v1".to_string())); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "task".to_string(), + cache_control: None, + }], + }); + + let _ = cache(&mut app, Some("inspect")); + let key1 = app.session.last_warmup_key.clone().expect("key1"); + + // Change the system prompt (static layer). + app.system_prompt = Some(SystemPrompt::Text("Base policy v2".to_string())); + let _ = cache(&mut app, Some("inspect")); + let key2 = app.session.last_warmup_key.clone().expect("key2"); + + assert_ne!( + key1, key2, + "warmup key must change when static prefix changes" + ); + } + + #[test] + fn warmup_key_does_not_change_when_user_message_changes() { + let mut app = create_test_app(); + app.system_prompt = Some(SystemPrompt::Text("Base policy".to_string())); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "first task".to_string(), + cache_control: None, + }], + }); + + let _ = cache(&mut app, Some("inspect")); + let key1 = app.session.last_warmup_key.clone().expect("key1"); + + // Change user message (dynamic layer). + if let Some(last) = app.api_messages.last_mut() + && let Some(ContentBlock::Text { text, .. }) = last.content.first_mut() + { + *text = "second task".to_string(); + } + let _ = cache(&mut app, Some("inspect")); + let key2 = app.session.last_warmup_key.clone().expect("key2"); + + assert_eq!( + key1, key2, + "warmup key must not change when user message changes" + ); + } + + #[test] + fn warmup_key_changes_when_skills_change() { + let mut app = create_test_app(); + app.system_prompt = Some(SystemPrompt::Text( + "Base policy\n\n## Skills\n\n- skill-a".to_string(), + )); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "task".to_string(), + cache_control: None, + }], + }); + + let _ = cache(&mut app, Some("inspect")); + let key1 = app.session.last_warmup_key.clone().expect("key1"); + + // Change skills content (static layer). + app.system_prompt = Some(SystemPrompt::Text( + "Base policy\n\n## Skills\n\n- skill-a\n- skill-b".to_string(), + )); + let _ = cache(&mut app, Some("inspect")); + let key2 = app.session.last_warmup_key.clone().expect("key2"); + + assert_ne!(key1, key2, "warmup key must change when skills change"); + } + + #[test] + fn warmup_status_displayed_in_inspect_output() { + let mut app = create_test_app(); + app.system_prompt = Some(SystemPrompt::Text("Base policy".to_string())); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "task".to_string(), + cache_control: None, + }], + }); + + // First inspect — no previous warmup. + let first = cache(&mut app, Some("inspect")) + .message + .expect("first inspect"); + assert!( + first.contains("Warmup status: no previous warmup"), + "got: {first}" + ); + + // Second inspect — same state, warmup key should match. + let second = cache(&mut app, Some("inspect")) + .message + .expect("second inspect"); + assert!(second.contains("Warmup status: valid"), "got: {second}"); + } + + #[test] + fn warmup_key_in_json_output() { + let mut app = create_test_app(); + app.system_prompt = Some(SystemPrompt::Text("Base policy".to_string())); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "task".to_string(), + cache_control: None, + }], + }); + + let _ = cache(&mut app, Some("inspect --json")); + // JSON mode should also store the warmup key. + assert!( + app.session.last_warmup_key.is_some(), + "warmup key must be stored after JSON inspect" + ); + } + + #[test] + fn cache_inspect_shows_tool_catalog_hash_when_available() { + let mut app = create_test_app(); + app.system_prompt = Some(SystemPrompt::Text("Base policy".to_string())); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "task".to_string(), + cache_control: None, + }], + }); + app.session.last_tool_catalog = Some(vec![test_tool("read_file")]); + + let result = cache(&mut app, Some("inspect")) + .message + .expect("inspect output"); + assert!( + result.contains("Tool catalog hash: ") && !result.contains("(no tools registered)"), + "got: {result}" + ); + } + + #[test] + fn cache_inspect_shows_no_tools_when_hash_unavailable() { + let mut app = create_test_app(); + app.system_prompt = Some(SystemPrompt::Text("Base policy".to_string())); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "task".to_string(), + cache_control: None, + }], + }); + // No tool catalog hash stored. + app.session.last_tool_catalog_hash = None; + + let result = cache(&mut app, Some("inspect")) + .message + .expect("inspect output"); + assert!( + result.contains("Tool catalog hash: (no tools registered)"), + "got: {result}" + ); + } + + #[test] + fn cache_inspect_shows_stable_prefix_hash() { + let mut app = create_test_app(); + app.system_prompt = Some(SystemPrompt::Text("Base policy".to_string())); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "task".to_string(), + cache_control: None, + }], + }); + + let result = cache(&mut app, Some("inspect")) + .message + .expect("inspect output"); + assert!( + result.contains("Stable prefix hash (excl. tools):"), + "got: {result}" + ); + } + + #[test] + fn cache_inspect_json_includes_tool_catalog_hash() { + let mut app = create_test_app(); + app.system_prompt = Some(SystemPrompt::Text("Base policy".to_string())); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "task".to_string(), + cache_control: None, + }], + }); + app.session.last_tool_catalog = Some(vec![test_tool("read_file")]); + + let result = cache(&mut app, Some("inspect --json")) + .message + .expect("json output"); + let parsed: serde_json::Value = serde_json::from_str(&result).expect("valid JSON"); + assert_eq!(parsed["tool_catalog_hash"].as_str().unwrap().len(), 64); + assert!( + parsed["stable_prefix_hash"].as_str().is_some(), + "stable_prefix_hash must be present in JSON output" + ); + } + + #[test] + fn tool_catalog_hash_changes_when_stored_hash_changes() { + let mut app = create_test_app(); + app.system_prompt = Some(SystemPrompt::Text("Base policy".to_string())); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "task".to_string(), + cache_control: None, + }], + }); + + app.session.last_tool_catalog = Some(vec![test_tool("alpha")]); + let _ = cache(&mut app, Some("inspect")).message.expect("first"); + let first_key = app.session.last_warmup_key.clone().expect("first key"); + + app.session.last_tool_catalog = Some(vec![test_tool("beta")]); + let second = cache(&mut app, Some("inspect")).message.expect("second"); + let second_key = app.session.last_warmup_key.clone().expect("second key"); + assert!(second.contains("Tool catalog hash: ")); + assert_ne!(first_key.tool_catalog_hash, second_key.tool_catalog_hash); + } + + #[test] + fn warmup_key_has_all_required_fields() { + let mut app = create_test_app(); + app.system_prompt = Some(SystemPrompt::Text("Base policy".to_string())); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "task".to_string(), + cache_control: None, + }], + }); + app.session.last_tool_catalog = Some(vec![test_tool("read_file")]); + app.session.last_base_url = Some("https://api.deepseek.com".to_string()); + + let _ = cache(&mut app, Some("inspect")); + let key = app.session.last_warmup_key.clone().expect("key"); + + assert!(!key.provider.is_empty(), "provider must be set"); + assert!(!key.model.is_empty(), "model must be set"); + assert_eq!(key.base_url, "https://api.deepseek.com"); + assert!( + !key.static_prefix_hash.is_empty(), + "static_prefix_hash must be set" + ); + assert_eq!(key.tool_catalog_hash.len(), 64); + assert!(key.project_pack_hash.is_empty()); + assert!(key.skills_hash.is_empty()); + } + + #[test] + fn warmup_key_changes_when_base_url_changes() { + let mut app = create_test_app(); + app.system_prompt = Some(SystemPrompt::Text("Base policy".to_string())); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "task".to_string(), + cache_control: None, + }], + }); + app.session.last_base_url = Some("https://api.deepseek.com".to_string()); + + let _ = cache(&mut app, Some("inspect")); + let key1 = app.session.last_warmup_key.clone().expect("key1"); + + app.session.last_base_url = Some("https://custom.endpoint.com".to_string()); + let _ = cache(&mut app, Some("inspect")); + let key2 = app.session.last_warmup_key.clone().expect("key2"); + + assert_ne!(key1, key2, "warmup key must change when base_url changes"); + assert_ne!(key1.base_url, key2.base_url); + } + + #[test] + fn warmup_key_changes_when_tool_catalog_hash_changes() { + let mut app = create_test_app(); + app.system_prompt = Some(SystemPrompt::Text("Base policy".to_string())); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "task".to_string(), + cache_control: None, + }], + }); + app.session.last_tool_catalog = Some(vec![test_tool("alpha")]); + + let _ = cache(&mut app, Some("inspect")); + let key1 = app.session.last_warmup_key.clone().expect("key1"); + + app.session.last_tool_catalog = Some(vec![test_tool("beta")]); + let _ = cache(&mut app, Some("inspect")); + let key2 = app.session.last_warmup_key.clone().expect("key2"); + + assert_ne!( + key1, key2, + "warmup key must change when tool_catalog_hash changes" + ); + } + + #[test] + fn warmup_key_changes_when_project_pack_or_skills_change() { + let mut app = create_test_app(); + app.system_prompt = Some(SystemPrompt::Text( + "Base policy\n\n## Project Context Pack\n\n\n{\"files\":[\"a.rs\"]}\n\n\n## Skills\n\n- rust: code" + .to_string(), + )); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "task".to_string(), + cache_control: None, + }], + }); + + let _ = cache(&mut app, Some("inspect")); + let key1 = app.session.last_warmup_key.clone().expect("key1"); + assert_eq!(key1.project_pack_hash.len(), 64); + assert_eq!(key1.skills_hash.len(), 64); + + app.system_prompt = Some(SystemPrompt::Text( + "Base policy\n\n## Project Context Pack\n\n\n{\"files\":[\"b.rs\"]}\n\n\n## Skills\n\n- rust: code" + .to_string(), + )); + let _ = cache(&mut app, Some("inspect")); + let key2 = app.session.last_warmup_key.clone().expect("key2"); + assert_ne!(key1.project_pack_hash, key2.project_pack_hash); + + app.system_prompt = Some(SystemPrompt::Text( + "Base policy\n\n## Project Context Pack\n\n\n{\"files\":[\"b.rs\"]}\n\n\n## Skills\n\n- go: code" + .to_string(), + )); + let _ = cache(&mut app, Some("inspect")); + let key3 = app.session.last_warmup_key.clone().expect("key3"); + assert_ne!(key2.skills_hash, key3.skills_hash); + } + + #[test] + fn warmup_key_does_not_depend_on_last_cache_inspection() { + let mut app = create_test_app(); + app.system_prompt = Some(SystemPrompt::Text("Base policy".to_string())); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "task".to_string(), + cache_control: None, + }], + }); + app.session.last_tool_catalog = Some(vec![test_tool("read_file")]); + app.session.last_base_url = Some("https://api.deepseek.com".to_string()); + + // Run inspect first to populate last_cache_inspection. + let _ = cache(&mut app, Some("inspect")); + let key_with_inspect = app.session.last_warmup_key.clone().expect("key"); + + // Clear last_cache_inspection but keep other state. + app.session.last_cache_inspection = None; + + // Run inspect again — key should be the same even without prior inspection. + let _ = cache(&mut app, Some("inspect")); + let key_without_inspect = app.session.last_warmup_key.clone().expect("key2"); + + assert_eq!( + key_with_inspect, key_without_inspect, + "warmup key must not depend on last_cache_inspection" + ); + } } /// Remove last message pair (user + assistant). diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 04b72ac52..2f6dd3641 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -21,7 +21,7 @@ use serde_json::json; use tokio::sync::{Mutex as AsyncMutex, RwLock, mpsc}; use tokio_util::sync::CancellationToken; -use crate::client::DeepSeekClient; +use crate::client::{DeepSeekClient, tool_catalog_hash}; use crate::compaction::{ CompactionConfig, compact_messages_safe, merge_system_prompts, should_compact, }; @@ -945,6 +945,9 @@ impl Engine { usage: turn.usage.clone(), status: TurnOutcomeStatus::Failed, error: Some(message), + tool_catalog: None, + tool_catalog_hash: None, + base_url: None, }) .await; return; @@ -1095,6 +1098,13 @@ impl Engine { build_model_tool_catalog(registry.to_api_tools_with_cache(true), mcp_tools, mode) }); + let tool_catalog_for_event = tools.clone(); + let tool_catalog_hash = tools.as_ref().map(|t| tool_catalog_hash(t)); + let base_url = self + .deepseek_client + .as_ref() + .map(|c| c.base_url().to_string()); + // Main turn loop let (status, error) = self .handle_deepseek_turn( @@ -1127,6 +1137,9 @@ impl Engine { usage: turn.usage, status, error, + tool_catalog: tool_catalog_for_event, + tool_catalog_hash, + base_url, }) .await; @@ -1164,6 +1177,9 @@ impl Engine { usage: zero_usage, status: TurnOutcomeStatus::Failed, error: Some(message), + tool_catalog: None, + tool_catalog_hash: None, + base_url: None, }) .await; return; @@ -1241,6 +1257,9 @@ impl Engine { usage: zero_usage, status: turn_status, error: turn_error, + tool_catalog: None, + tool_catalog_hash: None, + base_url: None, }) .await; } @@ -1335,6 +1354,9 @@ impl Engine { crate::core::events::TurnOutcomeStatus::Completed }, error: result.error, + tool_catalog: None, + tool_catalog_hash: None, + base_url: None, }) .await; } diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 108678f18..20ca3ca53 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -406,6 +406,91 @@ fn model_tool_catalog_sorts_each_partition_for_prefix_cache_stability() { ); } +#[test] +fn model_tool_catalog_serialization_is_stable_across_input_order() { + let first = build_model_tool_catalog( + vec![ + api_tool("read_file"), + api_tool("apply_patch"), + api_tool("exec_shell"), + ], + vec![api_tool("mcp_zoo_b"), api_tool("mcp_aardvark_a")], + AppMode::Agent, + ); + let second = build_model_tool_catalog( + vec![ + api_tool("exec_shell"), + api_tool("read_file"), + api_tool("apply_patch"), + ], + vec![api_tool("mcp_aardvark_a"), api_tool("mcp_zoo_b")], + AppMode::Agent, + ); + + assert_eq!( + serde_json::to_string(&first).expect("serialize first catalog"), + serde_json::to_string(&second).expect("serialize second catalog"), + "same tool set must produce identical wire-order bytes regardless of input order" + ); +} + +#[test] +fn model_tool_catalog_hash_is_stable_across_input_order() { + fn catalog_hash(native: Vec, mcp: Vec) -> String { + let catalog = build_model_tool_catalog(native, mcp, AppMode::Agent); + let json = serde_json::to_string(&catalog).expect("serialize"); + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(json.as_bytes()); + format!("{:x}", hasher.finalize()) + } + + let native = vec![ + api_tool("read_file"), + api_tool("apply_patch"), + api_tool("exec_shell"), + ]; + let mcp = vec![api_tool("mcp_zoo_b"), api_tool("mcp_aardvark_a")]; + + let hash_a = catalog_hash(native.clone(), mcp.clone()); + let hash_b = catalog_hash( + vec![ + api_tool("exec_shell"), + api_tool("read_file"), + api_tool("apply_patch"), + ], + vec![api_tool("mcp_aardvark_a"), api_tool("mcp_zoo_b")], + ); + + assert_eq!( + hash_a, hash_b, + "same tool set must produce identical hash regardless of input order" + ); +} + +#[test] +fn model_tool_catalog_hash_changes_when_tool_set_changes() { + fn catalog_hash(native: Vec, mcp: Vec) -> String { + let catalog = build_model_tool_catalog(native, mcp, AppMode::Agent); + let json = serde_json::to_string(&catalog).expect("serialize"); + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(json.as_bytes()); + format!("{:x}", hasher.finalize()) + } + + let hash_without = catalog_hash(vec![api_tool("read_file"), api_tool("apply_patch")], vec![]); + let hash_with = catalog_hash( + vec![api_tool("read_file"), api_tool("apply_patch")], + vec![api_tool("mcp_new_server_new_tool")], + ); + + assert_ne!( + hash_without, hash_with, + "adding a tool must change the catalog hash" + ); +} + #[test] fn active_tool_list_pushes_deferred_activations_to_the_tail() { // Regression for #263: when ToolSearch activates a deferred tool mid- diff --git a/crates/tui/src/core/events.rs b/crates/tui/src/core/events.rs index 41ca417fc..cbf2899d3 100644 --- a/crates/tui/src/core/events.rs +++ b/crates/tui/src/core/events.rs @@ -9,7 +9,7 @@ use serde_json::Value; use crate::core::coherence::CoherenceState; use crate::error_taxonomy::ErrorEnvelope; -use crate::models::{Message, SystemPrompt, Usage}; +use crate::models::{Message, SystemPrompt, Tool, Usage}; use crate::tools::spec::{ToolError, ToolResult}; use crate::tools::subagent::SubAgentResult; use crate::tools::user_input::UserInputRequest; @@ -92,6 +92,12 @@ pub enum Event { usage: Usage, status: TurnOutcomeStatus, error: Option, + /// Tool catalog sent with this turn's request. + tool_catalog: Option>, + /// SHA-256 of the tool catalog JSON sent with this turn's request. + tool_catalog_hash: Option, + /// The API base URL used for this turn. + base_url: Option, }, /// Context compaction started. diff --git a/crates/tui/src/lsp/diagnostics.rs b/crates/tui/src/lsp/diagnostics.rs index f45f81d35..57f8fff97 100644 --- a/crates/tui/src/lsp/diagnostics.rs +++ b/crates/tui/src/lsp/diagnostics.rs @@ -122,6 +122,8 @@ impl DiagnosticBlock { #[must_use] pub fn render_blocks(blocks: &[DiagnosticBlock]) -> String { let mut chunks = Vec::new(); + let mut blocks = blocks.iter().collect::>(); + blocks.sort_by(|a, b| a.file.cmp(&b.file)); for block in blocks { let rendered = block.render(); if !rendered.is_empty() { @@ -180,6 +182,37 @@ mod tests { assert!(block.render().is_empty()); } + #[test] + fn render_blocks_sorts_files_for_prefix_stability() { + let blocks = vec![ + DiagnosticBlock { + file: PathBuf::from("z.rs"), + items: vec![Diagnostic { + line: 1, + column: 1, + severity: Severity::Error, + message: "z".to_string(), + }], + }, + DiagnosticBlock { + file: PathBuf::from("a.rs"), + items: vec![Diagnostic { + line: 1, + column: 1, + severity: Severity::Error, + message: "a".to_string(), + }], + }, + ]; + + let rendered = render_blocks(&blocks); + + assert!( + rendered.find("file=\"a.rs\"").expect("a block") + < rendered.find("file=\"z.rs\"").expect("z block") + ); + } + #[test] fn truncate_caps_to_max() { let mut block = DiagnosticBlock { diff --git a/crates/tui/src/lsp/mod.rs b/crates/tui/src/lsp/mod.rs index 1519e4a09..0f25c62e3 100644 --- a/crates/tui/src/lsp/mod.rs +++ b/crates/tui/src/lsp/mod.rs @@ -197,11 +197,12 @@ impl LspManager { _ => false, }) .collect(); - items.sort_by_key(|d| match d.severity { - Severity::Error => 0u8, - Severity::Warning => 1u8, - Severity::Information => 2u8, - Severity::Hint => 3u8, + items.sort_by(|a, b| { + diagnostic_severity_rank(a.severity) + .cmp(&diagnostic_severity_rank(b.severity)) + .then_with(|| a.line.cmp(&b.line)) + .then_with(|| a.column.cmp(&b.column)) + .then_with(|| a.message.cmp(&b.message)) }); let mut block = DiagnosticBlock { file: relative_to_workspace(&self.workspace, file), @@ -264,6 +265,15 @@ impl LspManager { } } +fn diagnostic_severity_rank(severity: Severity) -> u8 { + match severity { + Severity::Error => 0, + Severity::Warning => 1, + Severity::Information => 2, + Severity::Hint => 3, + } +} + /// Render `path` relative to the workspace when possible. Falls back to /// `path.file_name()` (per the issue's hard rule about not using /// `display().to_string()` on the bare path) when relativization fails. @@ -448,6 +458,58 @@ pub(crate) mod tests { assert_eq!(block.items[1].severity, Severity::Warning); } + #[tokio::test] + async fn diagnostics_are_sorted_by_severity_location_and_message() { + let dir = tempfile::tempdir().unwrap(); + let mgr = LspManager::new( + LspConfig { + include_warnings: true, + ..LspConfig::default() + }, + dir.path().to_path_buf(), + ); + let path = dir.path().join("foo.rs"); + tokio::fs::write(&path, b"fn main() {}").await.unwrap(); + + let fake = Arc::new(FakeTransport::new(vec![ + Diagnostic { + line: 10, + column: 1, + severity: Severity::Warning, + message: "warning".to_string(), + }, + Diagnostic { + line: 2, + column: 5, + severity: Severity::Error, + message: "b error".to_string(), + }, + Diagnostic { + line: 2, + column: 1, + severity: Severity::Error, + message: "a error".to_string(), + }, + ])); + mgr.install_test_transport(Language::Rust, fake).await; + + let block = mgr.diagnostics_for(&path, 1).await.expect("has block"); + let order = block + .items + .iter() + .map(|item| (item.severity, item.line, item.column, item.message.as_str())) + .collect::>(); + + assert_eq!( + order, + vec![ + (Severity::Error, 2, 1, "a error"), + (Severity::Error, 2, 5, "b error"), + (Severity::Warning, 10, 1, "warning"), + ] + ); + } + #[tokio::test] async fn truncates_to_max_per_file() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/tui/src/mcp.rs b/crates/tui/src/mcp.rs index ae0e71b95..f47d5e9e2 100644 --- a/crates/tui/src/mcp.rs +++ b/crates/tui/src/mcp.rs @@ -1151,6 +1151,8 @@ impl McpConnection { break; } } + self.resources + .sort_by(|a, b| a.uri.cmp(&b.uri).then_with(|| a.name.cmp(&b.name))); Ok(()) } @@ -1194,6 +1196,11 @@ impl McpConnection { break; } } + self.resource_templates.sort_by(|a, b| { + a.uri_template + .cmp(&b.uri_template) + .then_with(|| a.name.cmp(&b.name)) + }); Ok(()) } @@ -1233,6 +1240,7 @@ impl McpConnection { break; } } + self.prompts.sort_by(|a, b| a.name.cmp(&b.name)); Ok(()) } @@ -1577,13 +1585,14 @@ impl McpPool { /// Connect to all enabled servers, returning errors for failed connections pub async fn connect_all(&mut self) -> Vec<(String, anyhow::Error)> { let mut errors = Vec::new(); - let names: Vec = self + let mut names: Vec = self .config .servers .keys() .filter(|n| self.config.servers[*n].is_enabled()) .cloned() .collect(); + names.sort(); for name in names { if let Err(e) = self.get_or_connect(&name).await { @@ -1591,16 +1600,21 @@ impl McpPool { } } - for (name, server_cfg) in &self.config.servers { + let mut required_names = self.config.servers.keys().cloned().collect::>(); + required_names.sort(); + for name in required_names { + let Some(server_cfg) = self.config.servers.get(&name) else { + continue; + }; if server_cfg.required && server_cfg.is_enabled() && !self .connections - .get(name) + .get(&name) .is_some_and(McpConnection::is_ready) { errors.push(( - name.clone(), + name, anyhow::anyhow!("required MCP server failed to initialize"), )); } @@ -1638,6 +1652,11 @@ impl McpPool { resources.push((format!("mcp_{}_{}", server, safe_name), resource)); } } + resources.sort_by(|a, b| { + a.0.cmp(&b.0) + .then_with(|| a.1.uri.cmp(&b.1.uri)) + .then_with(|| a.1.name.cmp(&b.1.name)) + }); resources } @@ -1651,13 +1670,18 @@ impl McpPool { templates.push((format!("mcp_{}_{}", server, safe_name), template)); } } + templates.sort_by(|a, b| { + a.0.cmp(&b.0) + .then_with(|| a.1.uri_template.cmp(&b.1.uri_template)) + .then_with(|| a.1.name.cmp(&b.1.name)) + }); templates } async fn list_resources(&mut self, server: Option) -> Result> { if let Some(server_name) = server { let conn = self.get_or_connect(&server_name).await?; - let resources = conn + let mut resources: Vec<_> = conn .resources() .iter() .map(|resource| { @@ -1670,6 +1694,7 @@ impl McpPool { }) }) .collect(); + resources.sort_by(resource_list_item_cmp); return Ok(resources); } @@ -1686,6 +1711,7 @@ impl McpPool { })); } } + items.sort_by(resource_list_item_cmp); Ok(items) } @@ -1695,7 +1721,7 @@ impl McpPool { ) -> Result> { if let Some(server_name) = server { let conn = self.get_or_connect(&server_name).await?; - let templates = conn + let mut templates: Vec<_> = conn .resource_templates() .iter() .map(|template| { @@ -1708,6 +1734,7 @@ impl McpPool { }) }) .collect(); + templates.sort_by(resource_list_item_cmp); return Ok(templates); } @@ -1724,6 +1751,7 @@ impl McpPool { })); } } + items.sort_by(resource_list_item_cmp); Ok(items) } @@ -1736,6 +1764,7 @@ impl McpPool { prompts.push((format!("mcp_{}_{}", server, prompt.name), prompt)); } } + prompts.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.name.cmp(&b.1.name))); prompts } @@ -1782,11 +1811,13 @@ impl McpPool { // Add regular tools for (name, tool) in self.all_tools() { + let mut input_schema = tool.input_schema.clone(); + crate::tools::schema_sanitize::stabilize_for_prefix(&mut input_schema); api_tools.push(crate::models::Tool { tool_type: None, name, description: tool.description.clone().unwrap_or_default(), - input_schema: tool.input_schema.clone(), + input_schema, allowed_callers: Some(vec!["direct".to_string()]), defer_loading: Some(false), input_examples: None, @@ -2376,6 +2407,30 @@ fn snapshot_from_config( // === Helper Functions === +fn resource_list_item_cmp(a: &serde_json::Value, b: &serde_json::Value) -> std::cmp::Ordering { + resource_list_item_key(a).cmp(&resource_list_item_key(b)) +} + +fn resource_list_item_key(value: &serde_json::Value) -> (String, String, String) { + let server = value + .get("server") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + let uri = value + .get("uri") + .or_else(|| value.get("uri_template")) + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + let name = value + .get("name") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + (server, uri, name) +} + /// Format MCP tool result for display #[allow(dead_code)] // Will be used when MCP tool results are displayed in TUI pub fn format_tool_result(result: &serde_json::Value) -> String { @@ -2670,6 +2725,164 @@ mod tests { serde_json::to_vec(&value).unwrap() } + #[tokio::test] + async fn pool_orders_mcp_resources_prompts_and_schema_required_stably() { + let sent = Arc::new(Mutex::new(Vec::new())); + let mut alpha = test_connection(Box::new(ScriptedValueTransport { + sent: Arc::clone(&sent), + responses: VecDeque::new(), + })); + alpha.tools = vec![McpTool { + name: "lookup".to_string(), + description: Some("Lookup".to_string()), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "zeta": {"type": "string"}, + "alpha": {"type": "string"} + }, + "required": ["zeta", "alpha"] + }), + }]; + alpha.resources = vec![ + McpResource { + uri: "file:///z".to_string(), + name: "Zed".to_string(), + description: None, + mime_type: None, + }, + McpResource { + uri: "file:///a".to_string(), + name: "Alpha".to_string(), + description: None, + mime_type: None, + }, + ]; + alpha.resource_templates = vec![ + McpResourceTemplate { + uri_template: "file:///{z}".to_string(), + name: "Zed Template".to_string(), + description: None, + mime_type: None, + }, + McpResourceTemplate { + uri_template: "file:///{a}".to_string(), + name: "Alpha Template".to_string(), + description: None, + mime_type: None, + }, + ]; + alpha.prompts = vec![ + McpPrompt { + name: "summarize".to_string(), + description: None, + arguments: Vec::new(), + }, + McpPrompt { + name: "analyze".to_string(), + description: None, + arguments: Vec::new(), + }, + ]; + + let mut beta = test_connection(Box::new(ScriptedValueTransport { + sent, + responses: VecDeque::new(), + })); + beta.resources = vec![McpResource { + uri: "file:///b".to_string(), + name: "Beta".to_string(), + description: None, + mime_type: None, + }]; + + let mut pool = McpPool::new(McpConfig::default()); + pool.connections.insert("beta".to_string(), beta); + pool.connections.insert("alpha".to_string(), alpha); + + let resources = pool + .all_resources() + .into_iter() + .map(|(_, resource)| resource.uri.as_str()) + .collect::>(); + assert_eq!(resources, vec!["file:///a", "file:///z", "file:///b"]); + + let templates = pool + .all_resource_templates() + .into_iter() + .map(|(_, template)| template.uri_template.as_str()) + .collect::>(); + assert_eq!(templates, vec!["file:///{a}", "file:///{z}"]); + + let prompts = pool + .all_prompts() + .into_iter() + .map(|(_, prompt)| prompt.name.as_str()) + .collect::>(); + assert_eq!(prompts, vec!["analyze", "summarize"]); + + let listed_resources = pool.list_resources(None).await.expect("list resources"); + let listed_uris = listed_resources + .iter() + .map(|item| item["uri"].as_str().unwrap()) + .collect::>(); + assert_eq!(listed_uris, vec!["file:///a", "file:///z", "file:///b"]); + + let tools = pool.to_api_tools(); + let lookup = tools + .iter() + .find(|tool| tool.name == "mcp_alpha_lookup") + .expect("lookup tool"); + assert_eq!( + lookup.input_schema["required"], + serde_json::json!(["alpha", "zeta"]) + ); + } + + #[tokio::test] + async fn to_api_tools_is_stable_regardless_of_connection_insertion_order() { + fn pool_with_order(order: &[&str]) -> McpPool { + let mut pool = McpPool::new(McpConfig::default()); + for name in order { + let mut conn = test_connection(Box::new(ScriptedValueTransport { + sent: Arc::new(Mutex::new(Vec::new())), + responses: VecDeque::new(), + })); + conn.tools = vec![McpTool { + name: format!("{name}_tool"), + description: Some(format!("Tool from {name}")), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "z_arg": {"type": "string"}, + "a_arg": {"type": "string"} + }, + "required": ["z_arg", "a_arg"] + }), + }]; + pool.connections.insert(name.to_string(), conn); + } + pool + } + + let ab = pool_with_order(&["alpha", "beta"]); + let ba = pool_with_order(&["beta", "alpha"]); + + let tools_ab = ab.to_api_tools(); + let tools_ba = ba.to_api_tools(); + + let names_ab: Vec<&str> = tools_ab.iter().map(|t| t.name.as_str()).collect(); + let names_ba: Vec<&str> = tools_ba.iter().map(|t| t.name.as_str()).collect(); + assert_eq!(names_ab, names_ba, "tool names must be in the same order"); + + let json_ab = serde_json::to_string(&tools_ab).unwrap(); + let json_ba = serde_json::to_string(&tools_ba).unwrap(); + assert_eq!( + json_ab, json_ba, + "wire bytes must be identical regardless of connection insertion order" + ); + } + #[tokio::test] async fn call_method_skips_notifications_and_unmatched_responses() { let sent = Arc::new(Mutex::new(Vec::new())); diff --git a/crates/tui/src/project_context.rs b/crates/tui/src/project_context.rs index f5a3d800a..163cacdda 100644 --- a/crates/tui/src/project_context.rs +++ b/crates/tui/src/project_context.rs @@ -12,10 +12,14 @@ //! context about the project's conventions, structure, and requirements. use std::collections::BTreeMap; +use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use ignore::{DirEntry, WalkBuilder}; use serde::Serialize; +use sha2::{Digest, Sha256}; use thiserror::Error; /// Names of project context files to look for, in priority order. @@ -46,11 +50,26 @@ const PACK_IGNORED_DIRS: &[&str] = &[ "dist", "build", "target", + ".next", + ".cache", + "coverage", + "logs", + "tmp", + "temp", + ".tmp", ".idea", ".vscode", ".pytest_cache", - ".DS_Store", ]; +const PACK_IGNORED_FILES: &[&str] = &[".ds_store", "thumbs.db"]; + +#[derive(Debug, Clone)] +struct CachedProjectPack { + manifest_hash: String, + rendered: String, +} + +static PROJECT_PACK_CACHE: OnceLock>> = OnceLock::new(); // === Errors === @@ -146,18 +165,38 @@ struct ReadmePack { /// sorted entries, bounded README text, and sorted JSON object fields. It does /// not include timestamps, random ids, absolute temp paths, or live git state. pub fn generate_project_context_pack(workspace: &Path) -> Option { + let cache_key = fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf()); + + // Always walk the directory to compute entries + readme excerpt. + // The manifest_hash is the authoritative cache key — it covers + // the file list AND the README excerpt content, so it catches + // content changes that directory mtime alone would miss. let mut entries = Vec::new(); - collect_pack_entries(workspace, workspace, 0, &mut entries); - entries.sort(); + collect_pack_entries(workspace, &mut entries); + sort_pack_paths(&mut entries); entries.truncate(PACK_MAX_ENTRIES); + let readme = read_readme_excerpt(workspace, &entries); + let manifest_hash = project_pack_manifest_hash(&entries, readme.as_ref()); + + // Check cache: if manifest_hash matches, reuse the rendered pack. + if let Some(cached) = PROJECT_PACK_CACHE + .get_or_init(|| Mutex::new(HashMap::new())) + .lock() + .ok() + .and_then(|cache| cache.get(&cache_key).cloned()) + && cached.manifest_hash == manifest_hash + { + return Some(cached.rendered); + } + let mut config_files = entries .iter() .filter(|path| is_config_file(path)) .take(PACK_MAX_CONFIG_FILES) .cloned() .collect::>(); - config_files.sort(); + sort_pack_paths(&mut config_files); let mut key_source_files = entries .iter() @@ -165,9 +204,8 @@ pub fn generate_project_context_pack(workspace: &Path) -> Option { .take(PACK_MAX_SOURCE_FILES) .cloned() .collect::>(); - key_source_files.sort(); + sort_pack_paths(&mut key_source_files); - let readme = read_readme_excerpt(workspace, &entries); let mut counts = BTreeMap::new(); counts.insert("config_files".to_string(), config_files.len()); counts.insert("directory_entries".to_string(), entries.len()); @@ -187,59 +225,170 @@ pub fn generate_project_context_pack(workspace: &Path) -> Option { }; let json = serde_json::to_string_pretty(&pack).ok()?; - Some(format!( + let rendered = format!( "## Project Context Pack\n\n\n{json}\n" - )) -} - -fn collect_pack_entries(root: &Path, dir: &Path, depth: usize, out: &mut Vec) { - if depth > PACK_MAX_DEPTH || out.len() >= PACK_MAX_ENTRIES { - return; + ); + if let Ok(mut cache) = PROJECT_PACK_CACHE + .get_or_init(|| Mutex::new(HashMap::new())) + .lock() + { + cache.insert( + cache_key, + CachedProjectPack { + manifest_hash, + rendered: rendered.clone(), + }, + ); } + Some(rendered) +} - let Ok(read_dir) = fs::read_dir(dir) else { - return; - }; - let mut children = read_dir.filter_map(Result::ok).collect::>(); - children.sort_by_key(|entry| entry.path()); - - for entry in children { - if out.len() >= PACK_MAX_ENTRIES { - break; +fn collect_pack_entries(root: &Path, out: &mut Vec) { + let mut builder = WalkBuilder::new(root); + let root_for_filter = root.to_path_buf(); + builder + .max_depth(Some(PACK_MAX_DEPTH + 1)) + .follow_links(false) + .hidden(false) + .git_ignore(true) + .git_exclude(true) + .git_global(false) + .require_git(false) + .filter_entry(move |entry| should_walk_pack_entry(&root_for_filter, entry)); + let _ = builder.add_custom_ignore_filename(".deepseekignore"); + + for result in builder.build() { + let Ok(entry) = result else { + continue; + }; + if entry.depth() == 0 { + continue; } - let path = entry.path(); - let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + let Some(file_type) = entry.file_type() else { continue; }; - let Ok(file_type) = entry.file_type() else { + if file_type.is_symlink() { + continue; + } + let Some(relative) = relative_slash_path(root, entry.path()) else { continue; }; - if file_type.is_dir() && PACK_IGNORED_DIRS.contains(&name) { + if is_ignored_pack_path(&relative, file_type.is_dir()) { continue; } - - if let Some(relative) = relative_slash_path(root, &path) { - if file_type.is_dir() { - out.push(format!("{relative}/")); - collect_pack_entries(root, &path, depth + 1, out); - } else if file_type.is_file() { - out.push(relative); - } + if file_type.is_dir() { + out.push(format!("{relative}/")); + } else if file_type.is_file() { + out.push(relative); } } } +fn should_walk_pack_entry(root: &Path, entry: &DirEntry) -> bool { + if entry.depth() == 0 { + return true; + } + let Some(file_type) = entry.file_type() else { + return false; + }; + if file_type.is_symlink() { + return false; + } + let Some(relative) = relative_slash_path(root, entry.path()) else { + return false; + }; + !is_ignored_pack_path(&relative, file_type.is_dir()) +} + fn relative_slash_path(root: &Path, path: &Path) -> Option { let relative = path.strip_prefix(root).ok()?; let mut parts = Vec::new(); for component in relative.components() { parts.push(component.as_os_str().to_string_lossy().to_string()); } - if parts.is_empty() { - None + normalize_pack_relative_path(&parts.join("/")) +} + +fn normalize_pack_relative_path(path: &str) -> Option { + let normalized = path.replace('\\', "/"); + let mut parts = Vec::new(); + for part in normalized.split('/') { + if part.is_empty() || part == "." { + continue; + } + if part == ".." { + return None; + } + parts.push(part); + } + (!parts.is_empty()).then(|| parts.join("/")) +} + +fn sort_pack_paths(paths: &mut [String]) { + paths.sort_by(|a, b| { + pack_path_priority(a) + .cmp(&pack_path_priority(b)) + .then_with(|| pack_path_sort_key(a).cmp(&pack_path_sort_key(b))) + .then_with(|| a.cmp(b)) + }); +} + +fn pack_path_sort_key(path: &str) -> String { + path.replace('\\', "/").to_ascii_lowercase() +} + +fn pack_path_priority(path: &str) -> u8 { + let lower = pack_path_sort_key(path); + let name = lower.trim_end_matches('/').rsplit('/').next().unwrap_or(""); + if matches!(name, "readme.md" | "readme.txt" | "readme") { + 0 + } else if is_config_file(&lower) { + 1 + } else if is_source_file(&lower) { + 2 + } else if lower.ends_with('/') { + 3 } else { - Some(parts.join("/")) + 4 + } +} + +fn is_ignored_pack_path(relative: &str, is_dir: bool) -> bool { + let normalized = relative.trim_end_matches('/'); + let lower = normalized.to_ascii_lowercase(); + if lower + .split('/') + .any(|part| PACK_IGNORED_DIRS.contains(&part)) + { + return true; + } + if is_dir { + return false; + } + let name = lower.rsplit('/').next().unwrap_or(lower.as_str()); + PACK_IGNORED_FILES.contains(&name) + || name.ends_with(".log") + || name.ends_with(".tmp") + || name.ends_with(".temp") + || name.ends_with(".swp") + || name.ends_with(".swo") + || name.ends_with(".bak") + || name.ends_with('~') + || name.starts_with(".#") +} + +fn project_pack_manifest_hash(entries: &[String], readme: Option<&ReadmePack>) -> String { + let mut hasher = Sha256::new(); + for entry in entries { + hasher.update(entry.as_bytes()); + hasher.update([0]); + } + if let Some(readme) = readme { + hasher.update(readme.path.as_bytes()); + hasher.update([0]); + hasher.update(readme.excerpt.as_bytes()); } + format!("{:x}", hasher.finalize()) } fn read_readme_excerpt(workspace: &Path, entries: &[String]) -> Option { @@ -608,8 +757,19 @@ pub fn merge_contexts(contexts: &[ProjectContext]) -> Option { #[cfg(test)] mod tests { use super::*; + use sha2::{Digest, Sha256}; use tempfile::tempdir; + fn sha256_hex(text: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(text.as_bytes()); + format!("{:x}", hasher.finalize()) + } + + fn project_pack_hash(workspace: &Path) -> String { + sha256_hex(&generate_project_context_pack(workspace).expect("pack")) + } + #[test] fn test_load_project_context_empty() { let tmp = tempdir().expect("tempdir"); @@ -812,6 +972,11 @@ mod tests { let second = generate_project_context_pack(tmp.path()).expect("pack again"); assert_eq!(first, second); + assert_eq!( + sha256_hex(&first), + sha256_hex(&second), + "same project context must produce the same project pack hash" + ); assert!(first.contains("\"project_name\"")); assert!(first.contains("\"directory_structure\"")); assert!(first.contains("\"README.md\"")); @@ -825,6 +990,191 @@ mod tests { ); } + #[test] + fn project_context_pack_is_stable_across_creation_order() { + let left_root = tempdir().expect("left tempdir"); + let right_root = tempdir().expect("right tempdir"); + let left = left_root.path().join("repo"); + let right = right_root.path().join("repo"); + + fs::create_dir_all(left.join("src")).expect("mkdir left src"); + fs::write(left.join("README.md"), "# Demo\n\nStable README").expect("left readme"); + fs::write(left.join("Cargo.toml"), "[package]\nname = \"demo\"").expect("left cargo"); + fs::write(left.join("src").join("z.rs"), "mod z;").expect("left z"); + fs::write(left.join("src").join("a.rs"), "mod a;").expect("left a"); + + fs::create_dir_all(right.join("src")).expect("mkdir right src"); + fs::write(right.join("src").join("a.rs"), "mod a;").expect("right a"); + fs::write(right.join("src").join("z.rs"), "mod z;").expect("right z"); + fs::write(right.join("Cargo.toml"), "[package]\nname = \"demo\"").expect("right cargo"); + fs::write(right.join("README.md"), "# Demo\n\nStable README").expect("right readme"); + + assert_eq!( + generate_project_context_pack(&left), + generate_project_context_pack(&right) + ); + } + + #[test] + fn project_context_pack_ignores_volatile_paths() { + let tmp = tempdir().expect("tempdir"); + fs::write(tmp.path().join("README.md"), "# Demo\n\nReadme body").expect("write readme"); + fs::create_dir_all(tmp.path().join("src")).expect("mkdir src"); + fs::write(tmp.path().join("src").join("lib.rs"), "pub fn stable() {}").expect("write lib"); + + let before = project_pack_hash(tmp.path()); + + for dir in [ + ".git/objects", + "target/debug", + "node_modules/pkg", + "dist/assets", + "build/output", + ".next/cache", + "__pycache__", + ] { + fs::create_dir_all(tmp.path().join(dir)).expect("mkdir ignored dir"); + } + fs::write(tmp.path().join(".git").join("HEAD"), "ref: refs/heads/main").expect("write git"); + fs::write( + tmp.path().join("target").join("debug").join("build.log"), + "log", + ) + .expect("write target"); + fs::write( + tmp.path().join("node_modules").join("pkg").join("index.js"), + "ignored", + ) + .expect("write node_modules"); + fs::write( + tmp.path().join("dist").join("assets").join("app.js"), + "dist", + ) + .expect("write dist"); + fs::write( + tmp.path().join("build").join("output").join("app.js"), + "build", + ) + .expect("write build"); + fs::write(tmp.path().join(".next").join("cache").join("page"), "next").expect("write next"); + fs::write(tmp.path().join("__pycache__").join("mod.pyc"), "pyc").expect("write pycache"); + fs::write(tmp.path().join("run.log"), "log").expect("write log"); + fs::write(tmp.path().join("scratch.tmp"), "tmp").expect("write tmp"); + + let after_pack = generate_project_context_pack(tmp.path()).expect("pack after ignores"); + assert_eq!(before, sha256_hex(&after_pack)); + for ignored in [ + ".git", + "target", + "node_modules", + "dist", + "build", + ".next", + "__pycache__", + "run.log", + "scratch.tmp", + ] { + assert!( + !after_pack.contains(ignored), + "pack should not include ignored path {ignored}" + ); + } + } + + #[test] + fn project_context_pack_readme_hash_is_stable_when_content_is_unchanged() { + let tmp = tempdir().expect("tempdir"); + fs::write(tmp.path().join("README.md"), "# Demo\n\nStable body").expect("write readme"); + fs::create_dir_all(tmp.path().join("src")).expect("mkdir src"); + fs::write(tmp.path().join("src").join("lib.rs"), "pub fn stable() {}").expect("write lib"); + + let before = project_pack_hash(tmp.path()); + fs::write(tmp.path().join("README.md"), "# Demo\n\nStable body").expect("rewrite readme"); + let after = project_pack_hash(tmp.path()); + + assert_eq!(before, after); + } + + #[test] + fn project_context_pack_normalizes_windows_and_unix_paths_for_sorting() { + let mut windows_paths = vec![ + normalize_pack_relative_path(r"src\z.rs").expect("normalize z"), + normalize_pack_relative_path(r".\src\a.rs").expect("normalize a"), + normalize_pack_relative_path(r"config\DeepSeek.toml").expect("normalize config"), + ]; + let mut unix_paths = vec![ + normalize_pack_relative_path("src/z.rs").expect("normalize z"), + normalize_pack_relative_path("./src/a.rs").expect("normalize a"), + normalize_pack_relative_path("config/DeepSeek.toml").expect("normalize config"), + ]; + + sort_pack_paths(&mut windows_paths); + sort_pack_paths(&mut unix_paths); + + assert_eq!(windows_paths, unix_paths); + assert_eq!( + unix_paths, + vec![ + "config/DeepSeek.toml".to_string(), + "src/a.rs".to_string(), + "src/z.rs".to_string(), + ] + ); + } + + #[test] + fn project_context_pack_respects_gitignore_and_deepseekignore() { + let tmp = tempdir().expect("tempdir"); + fs::write(tmp.path().join("README.md"), "# Demo").expect("write readme"); + fs::write(tmp.path().join(".gitignore"), "ignored-by-git.rs\n").expect("write gitignore"); + fs::write( + tmp.path().join(".deepseekignore"), + "ignored-by-deepseek.rs\n", + ) + .expect("write deepseekignore"); + fs::write(tmp.path().join("ignored-by-git.rs"), "ignored").expect("write git ignored"); + fs::write(tmp.path().join("ignored-by-deepseek.rs"), "ignored") + .expect("write deepseek ignored"); + fs::write(tmp.path().join("included.rs"), "included").expect("write included"); + + let pack = generate_project_context_pack(tmp.path()).expect("pack"); + + assert!(pack.contains("included.rs")); + assert!(!pack.contains("ignored-by-git.rs")); + assert!(!pack.contains("ignored-by-deepseek.rs")); + } + + #[test] + fn project_context_pack_does_not_include_symlink_outside_workspace() { + let tmp = tempdir().expect("workspace tempdir"); + let outside = tempdir().expect("outside tempdir"); + fs::write(tmp.path().join("README.md"), "# Demo").expect("write readme"); + fs::write( + outside.path().join("secret.rs"), + "pub const SECRET: &str = \"outside\";", + ) + .expect("write outside"); + + let link_path = tmp.path().join("linked-secret.rs"); + if !try_symlink_file(&outside.path().join("secret.rs"), &link_path) { + return; + } + + let pack = generate_project_context_pack(tmp.path()).expect("pack"); + assert!(!pack.contains("linked-secret.rs")); + assert!(!pack.contains("SECRET")); + } + + #[cfg(unix)] + fn try_symlink_file(target: &Path, link: &Path) -> bool { + std::os::unix::fs::symlink(target, link).is_ok() + } + + #[cfg(windows)] + fn try_symlink_file(target: &Path, link: &Path) -> bool { + std::os::windows::fs::symlink_file(target, link).is_ok() + } + #[test] fn test_load_global_agents_when_project_has_no_context() { let workspace = tempdir().expect("workspace tempdir"); @@ -892,4 +1242,194 @@ mod tests { .contains("Project Structure (Auto-generated)") ); } + + #[test] + fn project_context_pack_hash_changes_when_readme_content_changes() { + let tmp = tempdir().expect("tempdir"); + fs::write( + tmp.path().join("README.md"), + "# Original README\n\nOriginal body.", + ) + .expect("write readme"); + fs::create_dir_all(tmp.path().join("src")).expect("mkdir src"); + fs::write(tmp.path().join("src").join("lib.rs"), "pub fn stable() {}").expect("write lib"); + + let before = project_pack_hash(tmp.path()); + + // Change README content — hash must change. + fs::write( + tmp.path().join("README.md"), + "# Modified README\n\nNew body content.", + ) + .expect("rewrite readme"); + let after = project_pack_hash(tmp.path()); + + assert_ne!( + before, after, + "project_pack_hash must change when README content changes" + ); + } + + #[test] + fn project_context_pack_hash_stable_when_readme_unchanged() { + let tmp = tempdir().expect("tempdir"); + fs::write(tmp.path().join("README.md"), "# Stable README").expect("write readme"); + fs::create_dir_all(tmp.path().join("src")).expect("mkdir src"); + fs::write(tmp.path().join("src").join("main.rs"), "fn main() {}").expect("write main"); + + let first = project_pack_hash(tmp.path()); + // Touch a non-readme file without changing its content. + let second = project_pack_hash(tmp.path()); + + assert_eq!(first, second, "hash must be stable when nothing changes"); + } + + #[test] + fn project_context_pack_hash_ignores_target_directory_changes() { + let tmp = tempdir().expect("tempdir"); + fs::write(tmp.path().join("README.md"), "# Demo").expect("write readme"); + fs::create_dir_all(tmp.path().join("src")).expect("mkdir src"); + fs::write(tmp.path().join("src").join("lib.rs"), "pub fn x() {}").expect("write lib"); + + let before = project_pack_hash(tmp.path()); + + // Add files to target/ (ignored directory). + fs::create_dir_all(tmp.path().join("target").join("debug")).expect("mkdir target"); + fs::write( + tmp.path().join("target").join("debug").join("binary"), + "binary content", + ) + .expect("write target file"); + + let after = project_pack_hash(tmp.path()); + assert_eq!( + before, after, + "hash must not change when ignored directory (target/) changes" + ); + } + + #[test] + fn project_context_pack_hash_changes_when_new_file_added() { + let tmp = tempdir().expect("tempdir"); + fs::write(tmp.path().join("README.md"), "# Demo").expect("write readme"); + fs::create_dir_all(tmp.path().join("src")).expect("mkdir src"); + fs::write(tmp.path().join("src").join("lib.rs"), "pub fn x() {}").expect("write lib"); + + let before = project_pack_hash(tmp.path()); + + // Add a new source file that will appear in the pack. + fs::write( + tmp.path().join("src").join("new_module.rs"), + "pub fn new() {}", + ) + .expect("write new module"); + + let after = project_pack_hash(tmp.path()); + assert_ne!( + before, after, + "hash must change when a new file is added to the pack" + ); + } + + #[test] + fn project_context_pack_hash_changes_when_readme_excerpt_changes() { + let tmp = tempdir().expect("tempdir"); + let long_readme = "A".repeat(5000); + fs::write(tmp.path().join("README.md"), &long_readme).expect("write long readme"); + + let before = project_pack_hash(tmp.path()); + + // Change only the first 4000 chars (which become the excerpt). + let mut new_readme = "B".repeat(4000); + new_readme.push_str(&"A".repeat(1000)); + fs::write(tmp.path().join("README.md"), &new_readme).expect("write modified readme"); + + let after = project_pack_hash(tmp.path()); + assert_ne!( + before, after, + "hash must change when README excerpt content changes" + ); + } + + #[test] + fn project_context_pack_hash_ignored_file_changes() { + let tmp = tempdir().expect("tempdir"); + fs::write(tmp.path().join("README.md"), "# Demo").expect("write readme"); + fs::create_dir_all(tmp.path().join("src")).expect("mkdir src"); + fs::write(tmp.path().join("src").join("lib.rs"), "pub fn x() {}").expect("write lib"); + + let before = project_pack_hash(tmp.path()); + + // Add a .DS_Store file (ignored by PACK_IGNORED_FILES). + fs::write(tmp.path().join(".DS_Store"), "store data").expect("write ds_store"); + + let after = project_pack_hash(tmp.path()); + assert_eq!( + before, after, + "hash must not change when an ignored file (.DS_Store) is added" + ); + } + + #[test] + fn project_context_pack_truncation_is_deterministic() { + let tmp = tempdir().expect("tempdir"); + fs::write(tmp.path().join("README.md"), "# Big project").expect("write readme"); + + // Create more files than PACK_MAX_ENTRIES (400). + for i in 0..500 { + let name = format!("file_{:04}.rs", i); + fs::write(tmp.path().join(&name), format!("// file {i}")).expect("write file"); + } + + // Run twice — results must be identical (sort-then-truncate is stable). + let first = project_pack_hash(tmp.path()); + let second = project_pack_hash(tmp.path()); + assert_eq!( + first, second, + "pack hash must be deterministic with >400 candidates" + ); + } + + #[test] + fn project_context_pack_config_files_not_squeezed_out() { + let tmp = tempdir().expect("tempdir"); + fs::write(tmp.path().join("README.md"), "# Project").expect("write readme"); + + // Create many ordinary source files that sort before config files. + for i in 0..100 { + let name = format!("aaa_{:03}.rs", i); + fs::write(tmp.path().join(&name), format!("// {i}")).expect("write file"); + } + + // Create a recognized config file (Cargo.toml). + fs::write(tmp.path().join("Cargo.toml"), "[package]\nname = \"demo\"") + .expect("write cargo"); + + let pack = generate_project_context_pack(tmp.path()).expect("pack"); + // Cargo.toml is a recognized config file and must appear in the pack. + assert!( + pack.contains("Cargo.toml"), + "config file Cargo.toml must be present in pack" + ); + } + + #[test] + fn project_context_pack_prioritizes_readme_and_config_before_truncation() { + let tmp = tempdir().expect("tempdir"); + + for i in 0..600 { + let name = format!("aaa_{:03}.rs", i); + fs::write(tmp.path().join(&name), format!("// {i}")).expect("write file"); + } + fs::write(tmp.path().join("README.md"), "# Project").expect("write readme"); + fs::write(tmp.path().join("Cargo.toml"), "[package]\nname = \"demo\"") + .expect("write cargo"); + + let pack = generate_project_context_pack(tmp.path()).expect("pack"); + assert!(pack.contains("README.md"), "README must survive truncation"); + assert!( + pack.contains("Cargo.toml"), + "config file must survive truncation" + ); + } } diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index aa653b738..39314903d 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -308,9 +308,10 @@ pub fn system_prompt_for_mode_with_context( /// /// 1. mode prompt (compile-time constant) /// 2. project context / fallback (workspace-static) -/// 3. skills block (skills-dir-static) -/// 4. `## Context Management` (compile-time constant, Agent/Yolo only) -/// 5. compaction handoff template (compile-time constant) +/// 3. project context pack / environment / configured instructions +/// 4. skills block (skills-dir-static) +/// 5. `## Context Management` (compile-time constant, Agent/Yolo only) +/// 6. compaction handoff template (compile-time constant) /// 6. handoff block — file-backed; rewritten by `/compact` and on exit /// /// Anything appended after a volatile block forfeits the cache for the rest @@ -413,24 +414,6 @@ pub fn system_prompt_for_mode_with_context_skills_session_and_approval( full_prompt = format!("{full_prompt}\n\n{block}"); } - // 2.5b. User memory block (#489). Goes above skills/context-management - // because it's session-stable: the memory file changes when the user - // edits it via `/memory` or `# foo` quick-add, but not turn-over-turn. - if let Some(memory_block) = session_context.user_memory_block - && !memory_block.trim().is_empty() - { - full_prompt = format!("{full_prompt}\n\n{memory_block}"); - } - - if let Some(goal_objective) = session_context.goal_objective - && !goal_objective.trim().is_empty() - { - full_prompt = format!( - "{full_prompt}\n\n## Current Session Goal\n\n\n{}\n", - goal_objective.trim() - ); - } - // 3. Skills block. #432: walks every candidate workspace // skills directory (`.agents/skills`, `skills`, // `.opencode/skills`, `.claude/skills`, `.cursor/skills`) plus global @@ -475,6 +458,25 @@ pub fn system_prompt_for_mode_with_context_skills_session_and_approval( // Everything below drifts mid-session and busts the prefix cache for // bytes that follow. Keep new static blocks above this comment. + // User memory and session goal are complete model context, but both can + // change during a run (`/memory`, quick-add memory, goal updates). Keep + // them below all static prompt layers so they do not invalidate the stable + // skills/context-management/compact-template prefix. + if let Some(memory_block) = session_context.user_memory_block + && !memory_block.trim().is_empty() + { + full_prompt = format!("{full_prompt}\n\n{memory_block}"); + } + + if let Some(goal_objective) = session_context.goal_objective + && !goal_objective.trim().is_empty() + { + full_prompt = format!( + "{full_prompt}\n\n## Current Session Goal\n\n\n{}\n", + goal_objective.trim() + ); + } + // 6. Previous-session handoff (file-backed, rewritten by `/compact`). if let Some(handoff_block) = load_handoff_block(workspace) { full_prompt = format!("{full_prompt}\n\n{handoff_block}"); @@ -786,7 +788,7 @@ mod tests { } #[test] - fn session_goal_is_injected_above_handoff_tail() { + fn session_goal_is_injected_after_static_prefix() { let tmp = tempdir().expect("tempdir"); let prompt = match system_prompt_for_mode_with_context_skills_and_session( AppMode::Agent, @@ -809,7 +811,7 @@ mod tests { let compact_pos = prompt.find("## Compaction Handoff").expect("compact block"); assert!(prompt.contains("Fix transcript corruption")); - assert!(goal_pos < compact_pos); + assert!(compact_pos < goal_pos); assert!(!prompt.contains("src/lib.rs")); } diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index 20110cc45..86c63521b 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -2533,6 +2533,9 @@ mod tests { }, status: TurnOutcomeStatus::Completed, error: None, + tool_catalog: None, + tool_catalog_hash: None, + base_url: None, }) .await; } @@ -2546,6 +2549,9 @@ mod tests { }, status: TurnOutcomeStatus::Completed, error: None, + tool_catalog: None, + tool_catalog_hash: None, + base_url: None, }) .await; } @@ -2678,6 +2684,9 @@ mod tests { }, status: TurnOutcomeStatus::Completed, error: None, + tool_catalog: None, + tool_catalog_hash: None, + base_url: None, }) .await; }); @@ -2800,6 +2809,9 @@ mod tests { }, status: TurnOutcomeStatus::Completed, error: None, + tool_catalog: None, + tool_catalog_hash: None, + base_url: None, }) .await; }); @@ -3019,6 +3031,9 @@ mod tests { }, status: TurnOutcomeStatus::Completed, error: None, + tool_catalog: None, + tool_catalog_hash: None, + base_url: None, }) .await; }); diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 0f992a14a..8e92afb66 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -2803,6 +2803,9 @@ impl RuntimeThreadManager { usage, status, error, + tool_catalog: _, + tool_catalog_hash: _, + base_url: _, } => { turn_usage = Some(usage); turn_status = match status { @@ -3627,6 +3630,9 @@ mod tests { }, status: TurnOutcomeStatus::Completed, error: None, + tool_catalog: None, + tool_catalog_hash: None, + base_url: None, }) .await; } @@ -3919,6 +3925,9 @@ mod tests { }, status: TurnOutcomeStatus::Completed, error: None, + tool_catalog: None, + tool_catalog_hash: None, + base_url: None, }) .await; if turn_index >= 2 { @@ -4153,6 +4162,9 @@ mod tests { }, status: TurnOutcomeStatus::Completed, error: None, + tool_catalog: None, + tool_catalog_hash: None, + base_url: None, }) .await?; @@ -4232,6 +4244,9 @@ mod tests { usage: Usage::default(), status: TurnOutcomeStatus::Completed, error: None, + tool_catalog: None, + tool_catalog_hash: None, + base_url: None, }) .await?; Ok(()) @@ -4307,6 +4322,9 @@ mod tests { usage: Usage::default(), status: TurnOutcomeStatus::Completed, error: None, + tool_catalog: None, + tool_catalog_hash: None, + base_url: None, }) .await?; Ok(()) @@ -4370,6 +4388,9 @@ mod tests { usage: Usage::default(), status: TurnOutcomeStatus::Completed, error: None, + tool_catalog: None, + tool_catalog_hash: None, + base_url: None, }) .await?; @@ -4490,6 +4511,9 @@ mod tests { usage: Usage::default(), status: TurnOutcomeStatus::Completed, error: None, + tool_catalog: None, + tool_catalog_hash: None, + base_url: None, }) .await?; Ok(()) @@ -4570,6 +4594,9 @@ mod tests { }, status: TurnOutcomeStatus::Completed, error: None, + tool_catalog: None, + tool_catalog_hash: None, + base_url: None, }) .await?; @@ -4631,6 +4658,9 @@ mod tests { }, status: TurnOutcomeStatus::Completed, error: None, + tool_catalog: None, + tool_catalog_hash: None, + base_url: None, }) .await; } @@ -4741,6 +4771,9 @@ mod tests { }, status: TurnOutcomeStatus::Completed, error: None, + tool_catalog: None, + tool_catalog_hash: None, + base_url: None, }) .await; } @@ -4771,6 +4804,9 @@ mod tests { }, status: TurnOutcomeStatus::Completed, error: None, + tool_catalog: None, + tool_catalog_hash: None, + base_url: None, }) .await; } diff --git a/crates/tui/src/skills/mod.rs b/crates/tui/src/skills/mod.rs index 30bbb77a9..b8f38838d 100644 --- a/crates/tui/src/skills/mod.rs +++ b/crates/tui/src/skills/mod.rs @@ -148,7 +148,10 @@ impl SkillRegistry { } }; - for entry in entries.flatten() { + let mut entries = entries.flatten().collect::>(); + entries.sort_by_key(|entry| entry.path()); + + for entry in entries { let path = entry.path(); // Skip hidden subdirectories. Common offenders are `.git`, // `.cache`, `.Trash`. The provided root itself is exempt: @@ -1061,6 +1064,30 @@ mod tests { assert!(rendered.contains("from-claude")); } + #[test] + fn render_skills_block_order_is_stable_regardless_of_push_order() { + // Verify that the rendered skills block produces the same output + // regardless of the order skills were added to the registry. + // The discover() function sorts by path, so this should be stable. + let tmpdir = TempDir::new().unwrap(); + let root = tmpdir.path().join("skills"); + + // Create skills in reverse alphabetical order on disk + for name in &["zebra", "mango", "alpha"] { + write_skill(&root, name, &format!("{name} skill"), "body"); + } + + let rendered = super::render_available_skills_context(&root).expect("non-empty"); + let alpha_pos = rendered.find("- alpha:").expect("alpha"); + let mango_pos = rendered.find("- mango:").expect("mango"); + let zebra_pos = rendered.find("- zebra:").expect("zebra"); + + assert!( + alpha_pos < mango_pos && mango_pos < zebra_pos, + "skills must be rendered in alphabetical order: alpha={alpha_pos}, mango={mango_pos}, zebra={zebra_pos}" + ); + } + /// Regression for the GitHub issue where users organize skills under /// vendor / category subdirectories (e.g. cloned skill repos that /// bundle several skills together). The old single-level `read_dir` diff --git a/crates/tui/src/tools/registry.rs b/crates/tui/src/tools/registry.rs index 68c277967..536cb64d4 100644 --- a/crates/tui/src/tools/registry.rs +++ b/crates/tui/src/tools/registry.rs @@ -222,6 +222,7 @@ impl ToolRegistry { .map(|tool| { let mut schema = tool.input_schema(); schema_sanitize::sanitize(&mut schema); + schema_sanitize::stabilize_for_prefix(&mut schema); Tool { tool_type: None, name: tool.name().to_string(), @@ -1238,6 +1239,33 @@ mod tests { assert_eq!(order_a, order_b); } + #[test] + fn to_api_tools_json_bytes_are_stable_across_registration_order() { + let tmp = tempdir().expect("tempdir"); + let ctx = ToolContext::new(tmp.path().to_path_buf()); + + let json_a = { + let mut registry = ToolRegistry::new(ctx.clone()); + registry.register(make_test_tool("zebra")); + registry.register(make_test_tool("alpha")); + registry.register(make_test_tool("mango")); + serde_json::to_string(®istry.to_api_tools()).unwrap() + }; + + let json_b = { + let mut registry = ToolRegistry::new(ctx.clone()); + registry.register(make_test_tool("alpha")); + registry.register(make_test_tool("mango")); + registry.register(make_test_tool("zebra")); + serde_json::to_string(®istry.to_api_tools()).unwrap() + }; + + assert_eq!( + json_a, json_b, + "wire bytes must be identical regardless of registration order" + ); + } + #[test] fn test_registry_remove() { let tmp = tempdir().expect("tempdir"); diff --git a/crates/tui/src/tools/schema_sanitize.rs b/crates/tui/src/tools/schema_sanitize.rs index 52d286b38..6c65f4ba0 100644 --- a/crates/tui/src/tools/schema_sanitize.rs +++ b/crates/tui/src/tools/schema_sanitize.rs @@ -250,6 +250,60 @@ fn ensure_properties_object(obj: &mut Map) -> &mut Map = map.into_iter().collect(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + let mut sorted = Map::new(); + for (k, mut v) in entries { + stabilize_for_prefix(&mut v); + sorted.insert(k, v); + } + obj.insert("properties".into(), Value::Object(sorted)); + } else { + obj.insert("properties".into(), props); + } + } + + // Recurse into remaining values (items, anyOf, oneOf, allOf, etc.) + // Collect keys first to avoid borrow conflict. + let keys: Vec = obj.keys().cloned().collect(); + for key in keys { + if key == "properties" || key == "required" { + continue; + } + if let Some(val) = obj.get_mut(&key) { + stabilize_for_prefix(val); + } + } + } else if let Some(arr) = schema.as_array_mut() { + for v in arr.iter_mut() { + stabilize_for_prefix(v); + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -603,4 +657,136 @@ mod tests { assert_eq!(tools[0].input_schema["required"], json!(["query"])); assert_eq!(tools[0].input_schema["additionalProperties"], false); } + + #[test] + fn stabilize_for_prefix_sorts_properties_keys() { + let mut schema = json!({ + "type": "object", + "properties": { + "zeta": {"type": "string"}, + "alpha": {"type": "string"}, + "mu": {"type": "integer"} + } + }); + stabilize_for_prefix(&mut schema); + let keys: Vec<&str> = schema["properties"] + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + assert_eq!(keys, vec!["alpha", "mu", "zeta"]); + } + + #[test] + fn stabilize_for_prefix_sorts_required_array() { + let mut schema = json!({ + "type": "object", + "properties": { + "zeta": {"type": "string"}, + "alpha": {"type": "string"} + }, + "required": ["zeta", "alpha"] + }); + stabilize_for_prefix(&mut schema); + assert_eq!(schema["required"], json!(["alpha", "zeta"])); + } + + #[test] + fn stabilize_for_prefix_sorts_nested_properties() { + let mut schema = json!({ + "type": "object", + "properties": { + "outer_z": { + "type": "object", + "properties": { + "inner_b": {"type": "string"}, + "inner_a": {"type": "string"} + }, + "required": ["inner_b", "inner_a"] + }, + "outer_a": {"type": "string"} + } + }); + stabilize_for_prefix(&mut schema); + + let outer_keys: Vec<&str> = schema["properties"] + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + assert_eq!(outer_keys, vec!["outer_a", "outer_z"]); + + let inner = &schema["properties"]["outer_z"]; + let inner_keys: Vec<&str> = inner["properties"] + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + assert_eq!(inner_keys, vec!["inner_a", "inner_b"]); + assert_eq!(inner["required"], json!(["inner_a", "inner_b"])); + } + + #[test] + fn stabilize_for_prefix_is_idempotent() { + let mut schema = json!({ + "type": "object", + "properties": { + "zeta": {"type": "string"}, + "alpha": {"type": "string"} + }, + "required": ["zeta", "alpha"] + }); + stabilize_for_prefix(&mut schema); + let first = schema.clone(); + stabilize_for_prefix(&mut schema); + assert_eq!(schema, first, "stabilize_for_prefix must be idempotent"); + } + + #[test] + fn stabilize_for_prefix_same_content_different_map_order_produces_same_json() { + // Build two schemas with the same logical content but different + // serde_json::Map iteration orders by inserting keys in opposite order. + let mut a = json!({ + "type": "object", + "properties": { + "zeta": {"type": "string"}, + "mu": {"type": "integer"}, + "alpha": {"type": "boolean"} + }, + "required": ["zeta", "mu", "alpha"] + }); + let mut b = json!({ + "type": "object", + "properties": { + "alpha": {"type": "boolean"}, + "mu": {"type": "integer"}, + "zeta": {"type": "string"} + }, + "required": ["alpha", "mu", "zeta"] + }); + + stabilize_for_prefix(&mut a); + stabilize_for_prefix(&mut b); + + assert_eq!( + serde_json::to_string(&a).unwrap(), + serde_json::to_string(&b).unwrap(), + "schemas with identical content must produce identical JSON after stabilization" + ); + } + + #[test] + fn stabilize_for_prefix_preserves_enum_order() { + // enum values are semantically ordered (positional); stabilize_for_prefix + // must NOT reorder them. + let mut schema = json!({ + "type": "string", + "enum": ["high", "medium", "low"] + }); + stabilize_for_prefix(&mut schema); + assert_eq!(schema["enum"], json!(["high", "medium", "low"])); + } } diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index c83009c30..8f7b5be64 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -9,7 +9,7 @@ use serde_json::Value; use thiserror::Error; use crate::artifacts::ArtifactRecord; -use crate::client::PromptInspection; +use crate::client::{CacheWarmupKey, PromptInspection}; use crate::compaction::CompactionConfig; use crate::config::{ ApiProvider, Config, DEFAULT_TEXT_MODEL, SavedCredential, has_api_key, save_api_key, @@ -19,7 +19,7 @@ use crate::core::coherence::CoherenceState; use crate::cycle_manager::{CycleBriefing, CycleConfig}; use crate::hooks::{HookContext, HookEvent, HookExecutor, HookResult}; use crate::localization::{Locale, MessageId, resolve_locale, tr}; -use crate::models::{Message, SystemPrompt, compaction_threshold_for_model_and_effort}; +use crate::models::{Message, SystemPrompt, Tool, compaction_threshold_for_model_and_effort}; use crate::palette::{self, UiTheme}; use crate::pricing::{CostCurrency, CostEstimate}; use crate::session_manager::SessionContextReference; @@ -633,6 +633,17 @@ pub struct SessionState { pub total_conversation_tokens: u32, pub turn_cache_history: VecDeque, pub last_cache_inspection: Option, + pub last_warmup_key: Option, + /// Tool catalog from the most recent API request. + /// Populated by the engine via `TurnComplete` so `/cache inspect` can + /// inspect the real tool schema without rebuilding the catalog. + pub last_tool_catalog: Option>, + /// SHA-256 of the tool catalog JSON from the most recent API request. + /// Populated by the engine via `TurnComplete`. + pub last_tool_catalog_hash: Option, + /// The API base URL used by the most recent request. + /// Populated by the engine via `TurnComplete`. + pub last_base_url: Option, } impl Default for SessionState { @@ -654,6 +665,10 @@ impl Default for SessionState { total_conversation_tokens: 0, turn_cache_history: VecDeque::new(), last_cache_inspection: None, + last_warmup_key: None, + last_tool_catalog: None, + last_tool_catalog_hash: None, + last_base_url: None, } } } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 4e333b3fc..6a023b417 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -30,7 +30,7 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use crate::audit::log_sensitive_event; use crate::automation_manager::{AutomationManager, AutomationSchedulerConfig, spawn_scheduler}; -use crate::client::{DeepSeekClient, build_cache_warmup_request}; +use crate::client::{CacheWarmupKey, DeepSeekClient, build_cache_warmup_request}; use crate::commands; use crate::compaction::estimate_input_tokens_conservative; use crate::config::{ApiProvider, Config, DEFAULT_NVIDIA_NIM_BASE_URL}; @@ -140,13 +140,11 @@ const DEFAULT_TERMINAL_PROBE_TIMEOUT_MS: u64 = 500; type AppTerminal = Terminal>; // Reset scroll region (`\x1b[r`), origin mode (`\x1b[?6l`), and home the cursor -// (`\x1b[H`) before letting ratatui's diff renderer repaint. The destructive -// `\x1b[2J\x1b[3J` pair was previously appended here to also wipe the visible -// screen and saved scrollback, but combined with the immediately-following -// `terminal.clear()` it produced a double-clear that several terminals -// (Ghostty, VSCode terminal, Win10 conhost) render as visible flicker on every -// TurnComplete / focus-gain / resize. The alt-screen buffer's double-buffering -// plus ratatui's `terminal.clear()` are sufficient to repaint cleanly. +// (`\x1b[H`) before letting ratatui's diff renderer repaint. We do NOT emit +// `\x1b[2J`/`\x1b[3J` (screen-wipe) here — `reset_terminal_viewport` uses +// `terminal.swap_buffers()` instead of `terminal.clear()` to force a full +// repaint without the visible blank frame that `\x1b[2J` causes on Ghostty, +// VSCode terminal, and Win10 conhost. See #flicker. const TERMINAL_ORIGIN_RESET: &[u8] = b"\x1b[r\x1b[?6l\x1b[H"; /// Run the interactive TUI event loop. @@ -883,7 +881,21 @@ async fn run_event_loop( usage, status, error, + tool_catalog, + tool_catalog_hash, + base_url, } => { + // Store tool catalog hash and base URL so /cache inspect + // and /cache warmup can use them without re-computation. + if let Some(catalog) = tool_catalog { + app.session.last_tool_catalog = Some(catalog); + } + if let Some(hash) = tool_catalog_hash { + app.session.last_tool_catalog_hash = Some(hash); + } + if let Some(url) = base_url { + app.session.last_base_url = Some(url); + } force_terminal_repaint = true; // Finalize any in-flight tool group. Cancellation // marks still-running entries as Failed so the user @@ -1691,7 +1703,11 @@ async fn run_event_loop( ); } - reset_terminal_viewport(terminal)?; + // `terminal.resize()` already calls `clear()` internally + // (which resets buffers and emits \x1b[2J). Only send the + // origin-reset escapes here — a second buffer swap would be + // redundant and the extra \x1b[2J from `clear()` causes flicker. + reset_terminal_origin(terminal)?; app.handle_resize(final_w, final_h); // #macos-resize: some terminals (macOS Terminal.app, Windows // ConHost) briefly report stale dimensions via @@ -3040,7 +3056,7 @@ async fn run_cache_warmup(app: &App, config: &Config) -> Result { messages: app.api_messages.clone(), max_tokens: 1024, system: app.system_prompt.clone(), - tools: None, + tools: app.session.last_tool_catalog.clone(), tool_choice: None, metadata: None, thinking: None, @@ -4703,6 +4719,39 @@ async fn apply_command_result( app.status_message = Some("Warming DeepSeek cache...".to_string()); match run_cache_warmup(app, config).await { Ok(usage) => { + // Compute warmup key directly from current app state. + // Does NOT depend on last_cache_inspection. + let reasoning_effort = if app.reasoning_effort == ReasoningEffort::Auto { + app.last_effective_reasoning_effort + .and_then(ReasoningEffort::api_value) + .map(str::to_string) + } else { + app.reasoning_effort.api_value().map(str::to_string) + }; + let request = MessageRequest { + model: app.model.clone(), + messages: app.api_messages.clone(), + max_tokens: 0, + system: app.system_prompt.clone(), + tools: app.session.last_tool_catalog.clone(), + tool_choice: None, + metadata: None, + thinking: None, + reasoning_effort, + stream: None, + temperature: None, + top_p: None, + }; + let inspection = crate::client::inspect_prompt_for_request(&request); + let base_url = app.session.last_base_url.clone().unwrap_or_default(); + let provider_str = format!("{:?}", app.api_provider); + app.session.last_warmup_key = Some(CacheWarmupKey::from_inspection( + &provider_str, + &app.model, + &base_url, + &inspection, + )); + let message = format_cache_warmup_result(&usage); app.add_message(HistoryCell::System { content: message.clone(), @@ -6597,18 +6646,27 @@ fn resume_terminal( Ok(()) } -fn reset_terminal_viewport(terminal: &mut AppTerminal) -> Result<()> { - // Reset scroll margins and origin mode before clearing. Some interactive - // child processes leave DECSTBM/DECOM behind; if ratatui's diff renderer - // then writes "row 0", terminals can place it relative to the leaked - // scroll region and the whole viewport appears shifted down. We - // deliberately do *not* emit CSI 2J/3J here — see TERMINAL_ORIGIN_RESET - // for why; the immediately-following ratatui `terminal.clear()` flushes a - // single clear via the diff renderer, which the alt-screen buffer absorbs - // without visible flicker on the affected terminals. +/// Send escape sequences to reset scroll margins, origin mode, and cursor +/// position. Some interactive child processes leave DECSTBM/DECOM behind; +/// if ratatui's diff renderer then writes "row 0", terminals can place it +/// relative to the leaked scroll region and the whole viewport appears +/// shifted down. +fn reset_terminal_origin(terminal: &mut AppTerminal) -> Result<()> { terminal.backend_mut().write_all(TERMINAL_ORIGIN_RESET)?; terminal.backend_mut().flush()?; - terminal.clear()?; + Ok(()) +} + +/// Reset terminal origin and force a full repaint on the next draw. +/// +/// Uses `swap_buffers()` instead of `clear()` to avoid emitting `\x1b[2J` +/// (screen-wipe), which creates a visible blank frame on Ghostty, VSCode +/// terminal, and Windows ConHost. `swap_buffers()` resets the back buffer +/// so the diff renderer writes every cell on the next draw, without the +/// intermediate blank frame. +fn reset_terminal_viewport(terminal: &mut AppTerminal) -> Result<()> { + reset_terminal_origin(terminal)?; + terminal.swap_buffers(); Ok(()) } diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 6afd80adc..cc4ec6dcb 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -67,8 +67,8 @@ prompt carries an extra block: ``` -The block sits above the volatile-content boundary in the prompt -assembly so it stays inside DeepSeek's prefix cache turn-over-turn. +The memory block is treated as dynamic prompt content because users +can edit it between turns. The file is read at every prompt-build call — edits via `/memory` or external editors land on the next turn, no restart needed.