feat(cache): cache-aware prompt diagnostics and wire payload optimization (#1196)

Merge of PR #1196 by wplll. Adds:

Cache-aware prompt layering:
  - PromptBuilder struct separates prompt construction from inspection
  - System prompt split into named layers with stability classification
  - Layers classified as static/history/dynamic for cache debugging

/cache inspect command:
  - SHA-256 hashes of each rendered prompt layer
  - Base static prefix hash vs full request prefix hash
  - Static prefix stability status across turns
  - First-divergence tracking from previous request

Wire payload optimization:
  - Tool result budget: large outputs compacted before API request
  - Tool result dedup: repeated outputs replaced by compact refs
  - Turn metadata dedup: repeated <turn_meta> blocks deduplicated
  - Wire-only: local session messages remain unchanged

Project context pack:
  - Deterministic workspace summary injected into stable prefix
  - Configurable via [context] project_pack = false

Cache warmup and improved footer cache display.
Thanks to wplll for the contribution.
This commit is contained in:
Hunter Bown committed 2026-05-09 00:20:13 -05:00
1 parent 97d79862c2
commit 54ca5718d2
14 files changed
+2031 -44

No files matched your search

+293
View File
@@ -891,6 +891,9 @@ pub(super) fn parse_usage(usage: Option<&Value>) -> Usage {
})
.and_then(Value::as_u64)
.unwrap_or(0);
let total_tokens = usage
.and_then(|u| u.get("total_tokens"))
.and_then(Value::as_u64);
let reasoning_tokens_raw = usage
.and_then(|u| u.get("completion_tokens_details"))
.and_then(|details| details.get("reasoning_tokens"))
@@ -899,6 +902,10 @@ pub(super) fn parse_usage(usage: Option<&Value>) -> Usage {
&& let Some(reasoning_tokens) = reasoning_tokens_raw
{
output_tokens = reasoning_tokens;
} else if output_tokens == 0
&& let Some(total_tokens) = total_tokens
{
output_tokens = total_tokens.saturating_sub(input_tokens);
}
let cached_tokens = usage
.and_then(|u| u.get("prompt_tokens_details"))
@@ -979,6 +986,16 @@ impl DeepSeekClient {
mod chat;
pub(crate) use chat::PromptInspection;
pub(crate) fn inspect_prompt_for_request(request: &MessageRequest) -> PromptInspection {
chat::inspect_prompt_for_request(request)
}
pub(crate) fn build_cache_warmup_request(request: &MessageRequest) -> MessageRequest {
chat::build_cache_warmup_request(request)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1370,6 +1387,267 @@ mod tests {
);
}
#[test]
fn prompt_builder_keeps_system_first_and_current_user_input_last() {
let request = MessageRequest {
model: "deepseek-v4-pro".to_string(),
messages: vec![
Message {
role: "assistant".to_string(),
content: vec![ContentBlock::Text {
text: "Previous answer".to_string(),
cache_control: None,
}],
},
Message {
role: "user".to_string(),
content: vec![
ContentBlock::Text {
text: "<turn_meta>\nCurrent local date: 2026-05-08\n</turn_meta>"
.to_string(),
cache_control: None,
},
ContentBlock::Text {
text: "Current user question".to_string(),
cache_control: None,
},
],
},
],
max_tokens: 1024,
system: Some(SystemPrompt::Text(
"Stable mode, project rules, and tool policy".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 out = build_chat_messages_for_request(&request);
assert_eq!(out[0].get("role").and_then(Value::as_str), Some("system"));
assert_eq!(
out[0].get("content").and_then(Value::as_str),
Some("Stable mode, project rules, and tool policy")
);
let last = out.last().expect("latest user message");
assert_eq!(last.get("role").and_then(Value::as_str), Some("user"));
assert!(
last.get("content")
.and_then(Value::as_str)
.is_some_and(|content| content.ends_with("Current user question")),
"current-turn user input must be at the tail of the wire prompt: {last:?}"
);
}
#[test]
fn prompt_inspect_reports_stable_layers_and_dynamic_user_task() {
let request = MessageRequest {
model: "deepseek-v4-pro".to_string(),
messages: vec![
Message {
role: "assistant".to_string(),
content: vec![ContentBlock::Text {
text: "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<project_instructions source=\"AGENTS.md\">\nRules\n</project_instructions>\n\n## Project Context Pack\n\n<project_context_pack>\n{}\n</project_context_pack>\n\n## Environment\n\n- lang: en"
.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 inspection = inspect_prompt_for_request(&request);
assert_eq!(inspection.base_static_prefix_hash.len(), 64);
assert_eq!(inspection.full_request_prefix_hash.len(), 64);
assert!(inspection.layers.iter().any(|layer| {
layer.name == "Global system prefix"
&& layer.stability.label() == "static"
&& layer.char_len == "Base policy".chars().count()
&& layer.sha256.len() == 64
}));
assert!(inspection.layers.iter().any(|layer| {
layer.name == "Project context" && layer.stability.label() == "static"
}));
assert!(inspection.layers.iter().any(|layer| {
layer.name == "Project context pack" && layer.stability.label() == "static"
}));
assert!(inspection.layers.iter().any(|layer| {
layer.name == "Message #1 assistant" && layer.stability.label() == "history"
}));
assert!(
inspection.layers.last().is_some_and(
|layer| layer.name == "User task" && layer.stability.label() == "dynamic"
)
);
}
#[test]
fn prompt_inspect_keeps_static_base_hash_across_different_user_tasks() {
fn request_with_user_task(task: &str) -> MessageRequest {
MessageRequest {
model: "deepseek-v4-pro".to_string(),
messages: vec![
Message {
role: "assistant".to_string(),
content: vec![ContentBlock::Text {
text: "Prior answer".to_string(),
cache_control: None,
}],
},
Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: 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\n\n## Context Management\n\nKeep concise\n\n## Compact\n\nTemplate"
.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_with_user_task("First task"));
let second = inspect_prompt_for_request(&request_with_user_task("Second task"));
let mut changed_history_request = request_with_user_task("Second task");
changed_history_request.messages[0] = Message {
role: "assistant".to_string(),
content: vec![ContentBlock::Text {
text: "Different prior answer".to_string(),
cache_control: None,
}],
};
let changed_history = inspect_prompt_for_request(&changed_history_request);
assert_eq!(
first.base_static_prefix_hash,
second.base_static_prefix_hash
);
assert_eq!(
first.full_request_prefix_hash, second.full_request_prefix_hash,
"full request prefix excludes the final dynamic user task"
);
assert_ne!(
second.full_request_prefix_hash, changed_history.full_request_prefix_hash,
"full request prefix can change when session history changes"
);
assert!(
second.layers.last().is_some_and(
|layer| layer.name == "User task" && layer.stability.label() == "dynamic"
),
"current user task must remain the final layer"
);
assert!(second.layers.iter().any(|layer| {
layer.name == "Message #1 assistant" && layer.stability.label() == "history"
}));
assert!(!second.layers.iter().any(
|layer| layer.name.starts_with("Message #") && layer.stability.label() == "static"
));
}
#[test]
fn cache_warmup_request_reuses_stable_prefix_and_fixed_user_tail() {
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: "Dynamic latest user task".to_string(),
cache_control: None,
}],
},
],
max_tokens: 1024,
system: Some(SystemPrompt::Text(
"Base policy\n\n<project_instructions source=\"AGENTS.md\">\nStable project rules\n</project_instructions>\n\n## Previous Session Handoff\n\nDynamic handoff"
.to_string(),
)),
tools: None,
tool_choice: None,
metadata: None,
thinking: None,
reasoning_effort: Some("max".to_string()),
stream: Some(true),
temperature: Some(0.7),
top_p: None,
};
let warmup = build_cache_warmup_request(&request);
assert_eq!(warmup.max_tokens, 8);
assert_eq!(warmup.temperature, Some(0.0));
assert_eq!(warmup.reasoning_effort.as_deref(), Some("max"));
assert_eq!(warmup.messages.len(), 2);
assert_eq!(warmup.messages[0].role, "assistant");
assert_eq!(warmup.messages[1].role, "user");
assert_eq!(
warmup.messages[1].content,
vec![ContentBlock::Text {
text: "请只回复 OK".to_string(),
cache_control: None,
}]
);
let wire = build_chat_messages_for_request(&warmup);
let system = wire
.first()
.and_then(|value| value.get("content"))
.and_then(Value::as_str)
.expect("warmup system prompt");
assert!(system.contains("Stable project rules"));
assert!(!system.contains("Dynamic handoff"));
assert!(
!wire
.iter()
.any(|value| value.to_string().contains("Dynamic latest user task")),
"warmup must not include the dynamic latest user task"
);
}
#[test]
fn reasoning_effort_uses_deepseek_top_level_thinking_parameter() {
let mut body = json!({});
@@ -2042,6 +2320,21 @@ mod tests {
);
}
#[test]
fn parse_usage_derives_completion_tokens_from_total_tokens_when_needed() {
let usage = parse_usage(Some(&json!({
"prompt_tokens": 100,
"total_tokens": 125,
"prompt_cache_hit_tokens": 70,
"prompt_cache_miss_tokens": 30
})));
assert_eq!(usage.input_tokens, 100);
assert_eq!(usage.output_tokens, 25);
assert_eq!(usage.prompt_cache_hit_tokens, Some(70));
assert_eq!(usage.prompt_cache_miss_tokens, Some(30));
}
#[test]
fn parse_usage_reads_v4_prompt_tokens_details_cached_tokens() {
let usage = parse_usage(Some(&json!({
File diff suppressed because it is too large. Load diff
+320 -4
View File
@@ -5,9 +5,10 @@
use std::time::Instant;
use super::CommandResult;
use crate::client::{PromptInspection, inspect_prompt_for_request};
use crate::compaction::estimate_input_tokens_conservative;
use crate::localization::{Locale, MessageId, tr};
use crate::models::{ContentBlock, SystemPrompt, context_window_for_model};
use crate::models::{ContentBlock, MessageRequest, SystemPrompt, context_window_for_model};
use crate::tui::app::{App, AppAction, TurnCacheRecord};
use crate::tui::history::HistoryCell;
@@ -136,9 +137,15 @@ pub fn context(_app: &mut App) -> CommandResult {
/// `arg` is parsed as a count override (default 10, capped at the ring size).
/// Renders a fixed-width table the user can paste into a bug report.
pub fn cache(app: &mut App, arg: Option<&str>) -> CommandResult {
let want = arg
.and_then(|s| s.trim().parse::<usize>().ok())
.unwrap_or(10);
let arg = arg.map(str::trim).filter(|s| !s.is_empty());
if matches!(arg, Some("inspect")) {
return CommandResult::message(format_cache_inspect(app));
}
if matches!(arg, Some("warmup")) {
return CommandResult::action(AppAction::CacheWarmup);
}
let want = arg.and_then(|s| s.parse::<usize>().ok()).unwrap_or(10);
let cap = app.session.turn_cache_history.len();
let count = want
.min(cap)
@@ -151,6 +158,150 @@ 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 {
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)
.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: None,
tool_choice: None,
metadata: None,
thinking: None,
reasoning_effort,
stream: Some(true),
temperature: None,
top_p: None,
};
let inspection = inspect_prompt_for_request(&request);
let previous = app.session.last_cache_inspection.as_ref();
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");
out.push_str(&format!(
"Base static prefix hash: {}\n",
inspection.base_static_prefix_hash
));
out.push_str(&format!(
"Full request prefix hash: {}\n",
inspection.full_request_prefix_hash
));
out.push_str(&format_static_prefix_status(previous, &inspection));
out.push_str(&format_first_divergence(previous, &inspection));
out.push('\n');
for layer in &inspection.layers {
let mut line = format!(
"{}: {}, chars={}, hash={}\n",
layer.name,
layer.stability.label(),
layer.char_len,
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",
tool_result.original_chars,
tool_result.sent_chars,
tool_result.truncated,
tool_result.deduplicated
);
}
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",
turn_meta.original_chars,
turn_meta.sent_chars,
turn_meta.deduplicated,
turn_meta.sha256
);
}
out.push_str(&line);
}
app.session.last_cache_inspection = Some(inspection);
out
}
fn format_static_prefix_status(
previous: Option<&PromptInspection>,
current: &PromptInspection,
) -> String {
let Some(previous) = previous else {
return "Static base prefix stability: no previous request\n".to_string();
};
if previous.base_static_prefix_hash == current.base_static_prefix_hash {
return "Static base prefix stability: OK\n".to_string();
}
let changed = changed_static_layers(previous, current);
if changed.is_empty() {
"Static base prefix stability: WARNING (base hash changed)\n".to_string()
} else {
format!(
"Static base prefix stability: WARNING changed layers: {}\n",
changed.join(", ")
)
}
}
fn format_first_divergence(
previous: Option<&PromptInspection>,
current: &PromptInspection,
) -> String {
let Some(previous) = previous else {
return "First divergence from previous request: unavailable\n".to_string();
};
let max_len = previous.layers.len().max(current.layers.len());
for index in 0..max_len {
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);
}
(Some(_), Some(curr)) => {
return format!("First divergence from previous request: {}\n", curr.name);
}
(None, Some(curr)) => {
return format!("First divergence from previous request: {}\n", curr.name);
}
(Some(prev), None) => {
return format!(
"First divergence from previous request: {} removed\n",
prev.name
);
}
(None, None) => break,
}
}
"First divergence from previous request: none\n".to_string()
}
fn changed_static_layers(previous: &PromptInspection, current: &PromptInspection) -> Vec<String> {
current
.layers
.iter()
.filter(|layer| layer.stability.label() == "static")
.filter(|layer| {
previous
.layers
.iter()
.find(|previous_layer| previous_layer.name == layer.name)
.is_none_or(|previous_layer| previous_layer.sha256 != layer.sha256)
})
.map(|layer| layer.name.clone())
.collect()
}
fn format_cache_history(app: &App, count: usize, locale: Locale) -> String {
let total = app.session.turn_cache_history.len();
let start = total.saturating_sub(count);
@@ -416,6 +567,171 @@ mod tests {
assert!(msg.contains("no turns recorded yet"), "got: {msg}");
}
#[test]
fn cache_inspect_reports_hashes_without_prompt_text() {
let mut app = create_test_app();
app.system_prompt = Some(SystemPrompt::Text(
"Base policy\n\n<project_instructions source=\"AGENTS.md\">\nSECRET_PROJECT_RULE\n</project_instructions>"
.to_string(),
));
app.api_messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "SECRET_USER_TASK".to_string(),
cache_control: None,
}],
});
let result = cache(&mut app, Some("inspect"));
let msg = result.message.expect("inspect output");
assert!(msg.contains("Cache Inspect"));
assert!(msg.contains("Base static prefix hash:"));
assert!(msg.contains("Full request prefix hash:"));
assert!(msg.contains("Static base prefix stability: no previous request"));
assert!(msg.contains("First divergence from previous request: unavailable"));
assert!(msg.contains("Global system prefix: static"));
assert!(msg.contains("Project context: static"));
assert!(msg.contains("User task: dynamic"));
assert!(!msg.contains("SECRET_PROJECT_RULE"));
assert!(!msg.contains("SECRET_USER_TASK"));
}
#[test]
fn cache_inspect_reports_divergence_from_previous_request() {
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: "assistant".to_string(),
content: vec![crate::models::ContentBlock::Text {
text: "Prior answer".to_string(),
cache_control: None,
}],
});
app.api_messages.push(Message {
role: "user".to_string(),
content: vec![crate::models::ContentBlock::Text {
text: "First task".to_string(),
cache_control: None,
}],
});
let first = cache(&mut app, Some("inspect"))
.message
.expect("first inspect output");
assert!(first.contains("Static base prefix stability: no previous request"));
if let Some(last) = app.api_messages.last_mut()
&& let Some(crate::models::ContentBlock::Text { text, .. }) = last.content.first_mut()
{
*text = "Second task".to_string();
}
let second = cache(&mut app, Some("inspect"))
.message
.expect("second inspect output");
assert!(second.contains("Static base prefix stability: OK"));
assert!(second.contains("First divergence from previous request: User task"));
assert!(second.contains("Message #1 assistant: history"));
}
#[test]
fn cache_inspect_displays_tool_result_budget_metadata() {
let mut app = create_test_app();
let long_output = format!("{}{}", "A".repeat(7_000), "Z".repeat(7_000));
app.api_messages.push(Message {
role: "assistant".to_string(),
content: vec![ContentBlock::ToolUse {
id: "tool-1".to_string(),
name: "shell_command".to_string(),
input: serde_json::json!({"command": "cargo test"}),
caller: None,
}],
});
app.api_messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: "tool-1".to_string(),
content: long_output.clone(),
is_error: None,
content_blocks: None,
}],
});
app.api_messages.push(Message {
role: "assistant".to_string(),
content: vec![ContentBlock::ToolUse {
id: "tool-2".to_string(),
name: "shell_command".to_string(),
input: serde_json::json!({"command": "cargo test"}),
caller: None,
}],
});
app.api_messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: "tool-2".to_string(),
content: long_output,
is_error: None,
content_blocks: None,
}],
});
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("truncated=true"), "got: {msg}");
assert!(msg.contains("deduplicated=false"), "got: {msg}");
assert!(msg.contains("deduplicated=true"), "got: {msg}");
}
#[test]
fn cache_inspect_displays_turn_meta_dedup_metadata() {
let mut app = create_test_app();
let turn_meta = format!(
"<turn_meta>\nCurrent local date: 2026-05-09\n{}\n</turn_meta>",
"Working set: src/lib.rs\n".repeat(20)
);
app.api_messages.push(Message {
role: "user".to_string(),
content: vec![
ContentBlock::Text {
text: turn_meta.clone(),
cache_control: None,
},
ContentBlock::Text {
text: "first task".to_string(),
cache_control: None,
},
],
});
app.api_messages.push(Message {
role: "user".to_string(),
content: vec![
ContentBlock::Text {
text: turn_meta,
cache_control: None,
},
ContentBlock::Text {
text: "second task".to_string(),
cache_control: None,
},
],
});
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("Working set: src/lib.rs"), "got: {msg}");
}
#[test]
fn cache_command_renders_recorded_turns_with_ratio() {
let mut app = create_test_app();
+20 -1
View File
@@ -488,7 +488,7 @@ pub const COMMANDS: &[CommandInfo] = &[
CommandInfo {
name: "cache",
aliases: &[],
usage: "/cache [count]",
usage: "/cache [count|inspect|warmup]",
description_id: MessageId::CmdCacheDescription,
},
];
@@ -927,6 +927,25 @@ mod tests {
));
}
#[test]
fn cache_inspect_dispatches_through_cache_command() {
let mut app = create_test_app();
let result = execute("/cache inspect", &mut app);
let msg = result.message.expect("cache inspect should return text");
assert!(msg.contains("Cache Inspect"));
assert!(msg.contains("Base static prefix hash:"));
assert!(msg.contains("Full request prefix hash:"));
assert!(result.action.is_none());
}
#[test]
fn cache_warmup_dispatches_action() {
let mut app = create_test_app();
let result = execute("/cache warmup", &mut app);
assert!(result.message.is_none());
assert!(matches!(result.action, Some(AppAction::CacheWarmup)));
}
#[test]
fn execute_config_opens_config_view_action() {
let mut app = create_test_app();
+22
View File
@@ -690,6 +690,10 @@ pub struct ContextConfig {
/// v0.7.5 audits V4 prefix-cache behavior.
#[serde(default)]
pub enabled: Option<bool>,
/// Include a deterministic project context pack in the stable prompt
/// prefix. Default: true; set `[context] project_pack = false` to disable.
#[serde(default)]
pub project_pack: Option<bool>,
/// Verbatim window: last N turns never summarized. Default: 16.
#[serde(default)]
pub verbatim_window_turns: Option<usize>,
@@ -1499,6 +1503,11 @@ impl Config {
.unwrap_or(false)
}
#[must_use]
pub fn project_context_pack_enabled(&self) -> bool {
self.context.project_pack.unwrap_or(true)
}
/// Return whether shell execution is allowed.
#[must_use]
pub fn allow_shell(&self) -> bool {
@@ -2444,6 +2453,10 @@ fn merge_config(base: Config, override_cfg: Config) -> Config {
lsp: override_cfg.lsp.or(base.lsp),
context: ContextConfig {
enabled: override_cfg.context.enabled.or(base.context.enabled),
project_pack: override_cfg
.context
.project_pack
.or(base.context.project_pack),
verbatim_window_turns: override_cfg
.context
.verbatim_window_turns
@@ -4104,6 +4117,15 @@ api_key = "old-openrouter-key"
assert_eq!(merged.context.enabled, Some(true));
}
#[test]
fn project_context_pack_defaults_on_and_can_be_disabled() {
let mut config = Config::default();
assert!(config.project_context_pack_enabled());
config.context.project_pack = Some(false);
assert!(!config.project_context_pack_enabled());
}
#[test]
fn validate_accepts_future_deepseek_model_id() -> Result<()> {
let config = Config {
+4
View File
@@ -95,6 +95,7 @@ pub struct EngineConfig {
/// `instructions = [...]` config (or the per-project override).
/// Resolved via `expand_path` so `~` works.
pub instructions: Vec<PathBuf>,
pub project_context_pack_enabled: bool,
/// Maximum number of assistant steps before stopping.
pub max_steps: u32,
/// Maximum number of concurrently active subagents.
@@ -166,6 +167,7 @@ impl Default for EngineConfig {
mcp_config_path: PathBuf::from("mcp.json"),
skills_dir: crate::skills::default_skills_dir(),
instructions: Vec::new(),
project_context_pack_enabled: true,
max_steps: 100,
max_subagents: DEFAULT_MAX_SUBAGENTS,
features: Features::with_defaults(),
@@ -442,6 +444,7 @@ impl Engine {
prompts::PromptSessionContext {
user_memory_block: user_memory_block.as_deref(),
goal_objective: config.goal_objective.as_deref(),
project_context_pack_enabled: config.project_context_pack_enabled,
locale_tag: &config.locale_tag,
},
session.approval_mode,
@@ -1862,6 +1865,7 @@ impl Engine {
prompts::PromptSessionContext {
user_memory_block: user_memory_block.as_deref(),
goal_objective: self.config.goal_objective.as_deref(),
project_context_pack_enabled: self.config.project_context_pack_enabled,
locale_tag: &self.config.locale_tag,
},
self.session.approval_mode,
+27
View File
@@ -829,6 +829,33 @@ fn turn_metadata_includes_current_local_date_without_working_set() {
assert!(text.contains(&format!("Current local date: {today}")));
}
#[test]
fn user_text_message_keeps_current_turn_input_after_turn_metadata() {
let tmp = tempdir().expect("tempdir");
let config = EngineConfig {
workspace: tmp.path().to_path_buf(),
..Default::default()
};
let (engine, _handle) = Engine::new(config, &Config::default());
let user_msg =
engine.user_text_message_with_turn_metadata("explain the cache metrics".to_string());
let last_text = user_msg
.content
.iter()
.rev()
.find_map(|block| {
if let ContentBlock::Text { text, .. } = block {
Some(text.as_str())
} else {
None
}
})
.expect("user text block");
assert_eq!(last_text, "explain the cache metrics");
}
#[test]
fn messages_with_turn_metadata_preserves_stored_messages_for_prefix_cache() {
let tmp = tempdir().expect("tempdir");
+1
View File
@@ -4186,6 +4186,7 @@ async fn run_exec_agent(
mcp_config_path: config.mcp_config_path(),
skills_dir: config.skills_dir(),
instructions: config.instructions_paths(),
project_context_pack_enabled: config.project_context_pack_enabled(),
max_steps: 100,
max_subagents,
features: config.features(),
+250
View File
@@ -11,9 +11,11 @@
//! The loaded content is injected into the system prompt to give the agent
//! context about the project's conventions, structure, and requirements.
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use serde::Serialize;
use thiserror::Error;
/// Names of project context files to look for, in priority order.
@@ -26,6 +28,25 @@ const PROJECT_CONTEXT_FILES: &[&str] = &[
/// Maximum size for project context files (to prevent loading huge files)
const MAX_CONTEXT_SIZE: usize = 100 * 1024; // 100KB
const PACK_README_MAX_CHARS: usize = 4_000;
const PACK_MAX_ENTRIES: usize = 400;
const PACK_MAX_SOURCE_FILES: usize = 80;
const PACK_MAX_CONFIG_FILES: usize = 80;
const PACK_MAX_DEPTH: usize = 4;
const PACK_IGNORED_DIRS: &[&str] = &[
".git",
"node_modules",
".venv",
"venv",
"__pycache__",
"dist",
"build",
"target",
".idea",
".vscode",
".pytest_cache",
".DS_Store",
];
// === Errors ===
@@ -99,6 +120,203 @@ impl ProjectContext {
}
}
#[derive(Debug, Serialize)]
struct ProjectContextPack {
project_name: String,
directory_structure: Vec<String>,
readme: Option<ReadmePack>,
config_files: Vec<String>,
key_source_files: Vec<String>,
counts: BTreeMap<String, usize>,
}
#[derive(Debug, Serialize)]
struct ReadmePack {
path: String,
excerpt: String,
}
/// Generate a deterministic, cache-friendly project context pack.
///
/// The pack intentionally uses only stable workspace facts: relative paths,
/// 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<String> {
let mut entries = Vec::new();
collect_pack_entries(workspace, workspace, 0, &mut entries);
entries.sort();
entries.truncate(PACK_MAX_ENTRIES);
let mut config_files = entries
.iter()
.filter(|path| is_config_file(path))
.take(PACK_MAX_CONFIG_FILES)
.cloned()
.collect::<Vec<_>>();
config_files.sort();
let mut key_source_files = entries
.iter()
.filter(|path| is_source_file(path))
.take(PACK_MAX_SOURCE_FILES)
.cloned()
.collect::<Vec<_>>();
key_source_files.sort();
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());
counts.insert("key_source_files".to_string(), key_source_files.len());
let pack = ProjectContextPack {
project_name: workspace
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("workspace")
.to_string(),
directory_structure: entries,
readme,
config_files,
key_source_files,
counts,
};
let json = serde_json::to_string_pretty(&pack).ok()?;
Some(format!(
"## Project Context Pack\n\n<project_context_pack>\n{json}\n</project_context_pack>"
))
}
fn collect_pack_entries(root: &Path, dir: &Path, depth: usize, out: &mut Vec<String>) {
if depth > PACK_MAX_DEPTH || out.len() >= PACK_MAX_ENTRIES {
return;
}
let Ok(read_dir) = fs::read_dir(dir) else {
return;
};
let mut children = read_dir.filter_map(Result::ok).collect::<Vec<_>>();
children.sort_by_key(|entry| entry.path());
for entry in children {
if out.len() >= PACK_MAX_ENTRIES {
break;
}
let path = entry.path();
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
continue;
};
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_dir() && PACK_IGNORED_DIRS.contains(&name) {
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);
}
}
}
}
fn relative_slash_path(root: &Path, path: &Path) -> Option<String> {
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
} else {
Some(parts.join("/"))
}
}
fn read_readme_excerpt(workspace: &Path, entries: &[String]) -> Option<ReadmePack> {
let path = entries
.iter()
.find(|path| {
let lower = path.to_ascii_lowercase();
lower == "readme.md" || lower == "readme.txt" || lower == "readme"
})?
.clone();
let raw = fs::read_to_string(workspace.join(&path)).ok()?;
let excerpt = truncate_chars(raw.trim(), PACK_README_MAX_CHARS);
if excerpt.is_empty() {
None
} else {
Some(ReadmePack { path, excerpt })
}
}
fn truncate_chars(value: &str, max_chars: usize) -> String {
if value.chars().count() <= max_chars {
return value.to_string();
}
value.chars().take(max_chars).collect::<String>()
}
fn is_config_file(path: &str) -> bool {
let lower = path.to_ascii_lowercase();
let name = lower.rsplit('/').next().unwrap_or(lower.as_str());
matches!(
name,
"cargo.toml"
| "package.json"
| "tsconfig.json"
| "pyproject.toml"
| "requirements.txt"
| "go.mod"
| "config.toml"
| "deepseek.toml"
| "dockerfile"
| "compose.yaml"
| "compose.yml"
| "docker-compose.yaml"
| "docker-compose.yml"
| "makefile"
) || lower.ends_with(".config.js")
|| lower.ends_with(".config.ts")
|| lower.ends_with(".toml")
|| lower.ends_with(".yaml")
|| lower.ends_with(".yml")
}
fn is_source_file(path: &str) -> bool {
let lower = path.to_ascii_lowercase();
matches!(
lower.rsplit('.').next(),
Some(
"rs" | "py"
| "js"
| "jsx"
| "ts"
| "tsx"
| "go"
| "java"
| "kt"
| "c"
| "cc"
| "cpp"
| "h"
| "hpp"
| "cs"
| "rb"
| "php"
| "swift"
| "sql"
| "sh"
| "bash"
)
)
}
/// Load project context from the workspace directory.
///
/// This searches for known project context files and loads the first one found.
@@ -528,4 +746,36 @@ mod tests {
.contains("Organization instructions")
);
}
#[test]
fn project_context_pack_is_stable_and_sorted() {
let tmp = tempdir().expect("tempdir");
fs::write(tmp.path().join("README.md"), "# Demo\n\nReadme body").expect("write");
fs::write(tmp.path().join("Cargo.toml"), "[package]\nname = \"demo\"").expect("write");
fs::create_dir_all(tmp.path().join("src")).expect("mkdir src");
fs::write(tmp.path().join("src").join("z.rs"), "mod z;").expect("write z");
fs::write(tmp.path().join("src").join("a.rs"), "mod a;").expect("write a");
fs::create_dir_all(tmp.path().join("node_modules").join("pkg")).expect("mkdir ignored");
fs::write(
tmp.path().join("node_modules").join("pkg").join("index.js"),
"ignored",
)
.expect("write ignored");
let first = generate_project_context_pack(tmp.path()).expect("pack");
let second = generate_project_context_pack(tmp.path()).expect("pack again");
assert_eq!(first, second);
assert!(first.contains("\"project_name\""));
assert!(first.contains("\"directory_structure\""));
assert!(first.contains("\"README.md\""));
assert!(first.contains("\"Cargo.toml\""));
assert!(first.contains("\"src/a.rs\""));
assert!(first.contains("\"src/z.rs\""));
assert!(!first.contains("node_modules"));
assert!(
first.find("\"src/a.rs\"").expect("a before z")
< first.find("\"src/z.rs\"").expect("z")
);
}
}
+64
View File
@@ -17,6 +17,7 @@ use std::path::{Path, PathBuf};
pub struct PromptSessionContext<'a> {
pub user_memory_block: Option<&'a str>,
pub goal_objective: Option<&'a str>,
pub project_context_pack_enabled: bool,
/// Resolved BCP-47 locale tag for the `## Environment` block in
/// the system prompt (e.g. `"en"`, `"zh-Hans"`, `"ja"`). The
/// caller is responsible for resolving this from `Settings`; no
@@ -333,6 +334,7 @@ pub fn system_prompt_for_mode_with_context_and_skills(
PromptSessionContext {
user_memory_block,
goal_objective: None,
project_context_pack_enabled: true,
locale_tag: "en",
},
)
@@ -383,6 +385,12 @@ pub fn system_prompt_for_mode_with_context_skills_session_and_approval(
mode_prompt
};
if session_context.project_context_pack_enabled
&& let Some(pack) = crate::project_context::generate_project_context_pack(workspace)
{
full_prompt = format!("{full_prompt}\n\n{pack}");
}
// 2.25. Environment block — locale, platform, shell, pwd. All
// four inputs are session-stable (workspace path is fixed for
// the run; locale is loaded once by the caller; platform/shell
@@ -545,6 +553,7 @@ mod tests {
PromptSessionContext {
user_memory_block: None,
goal_objective: None,
project_context_pack_enabled: true,
locale_tag: "ja",
},
) {
@@ -556,6 +565,59 @@ mod tests {
assert!(prompt.contains("- deepseek_version:"));
}
#[test]
fn project_context_pack_can_be_disabled() {
let tmp = tempdir().expect("tempdir");
std::fs::write(tmp.path().join("README.md"), "# Pack test").expect("write readme");
let prompt = match system_prompt_for_mode_with_context_skills_and_session(
AppMode::Agent,
tmp.path(),
None,
None,
None,
PromptSessionContext {
user_memory_block: None,
goal_objective: None,
project_context_pack_enabled: false,
locale_tag: "en",
},
) {
SystemPrompt::Text(text) => text,
SystemPrompt::Blocks(_) => panic!("expected text system prompt"),
};
assert!(!prompt.contains("<project_context_pack>"));
}
#[test]
fn project_context_pack_is_before_dynamic_tail() {
let tmp = tempdir().expect("tempdir");
std::fs::write(tmp.path().join("README.md"), "# Pack test").expect("write readme");
std::fs::create_dir_all(tmp.path().join(".deepseek")).expect("mkdir");
std::fs::write(tmp.path().join(".deepseek").join("handoff.md"), "handoff")
.expect("handoff");
let prompt = match system_prompt_for_mode_with_context_skills_and_session(
AppMode::Agent,
tmp.path(),
None,
None,
None,
PromptSessionContext {
user_memory_block: None,
goal_objective: None,
project_context_pack_enabled: true,
locale_tag: "en",
},
) {
SystemPrompt::Text(text) => text,
SystemPrompt::Blocks(_) => panic!("expected text system prompt"),
};
assert!(prompt.contains("<project_context_pack>"));
assert!(
prompt.find("<project_context_pack>").expect("pack")
< prompt.find("## Previous Session Handoff").expect("handoff")
);
}
#[test]
fn handoff_artifact_is_prepended_to_system_prompt_when_present() {
let tmp = tempdir().expect("tempdir");
@@ -716,6 +778,7 @@ mod tests {
PromptSessionContext {
user_memory_block: None,
goal_objective: Some("Fix transcript corruption"),
project_context_pack_enabled: true,
locale_tag: "en",
},
) {
@@ -743,6 +806,7 @@ mod tests {
PromptSessionContext {
user_memory_block: None,
goal_objective: Some(" "),
project_context_pack_enabled: true,
locale_tag: "en",
},
) {
+1
View File
@@ -1923,6 +1923,7 @@ impl RuntimeThreadManager {
mcp_config_path: self.config.mcp_config_path(),
skills_dir: self.config.skills_dir(),
instructions: self.config.instructions_paths(),
project_context_pack_enabled: self.config.project_context_pack_enabled(),
max_steps: 100,
max_subagents: self.config.max_subagents().clamp(1, MAX_SUBAGENTS),
features: self.config.features(),
+4
View File
@@ -8,6 +8,7 @@ use ratatui::layout::Rect;
use serde_json::Value;
use thiserror::Error;
use crate::client::PromptInspection;
use crate::compaction::CompactionConfig;
use crate::config::{
ApiProvider, Config, DEFAULT_TEXT_MODEL, SavedCredential, has_api_key, save_api_key,
@@ -626,6 +627,7 @@ pub struct SessionState {
pub total_tokens: u32,
pub total_conversation_tokens: u32,
pub turn_cache_history: VecDeque<TurnCacheRecord>,
pub last_cache_inspection: Option<PromptInspection>,
}
impl Default for SessionState {
@@ -646,6 +648,7 @@ impl Default for SessionState {
total_tokens: 0,
total_conversation_tokens: 0,
turn_cache_history: VecDeque::new(),
last_cache_inspection: None,
}
}
}
@@ -3818,6 +3821,7 @@ pub enum AppAction {
},
ListSubAgents,
FetchModels,
CacheWarmup,
/// Switch the active LLM backend (DeepSeek vs NVIDIA NIM) without
/// restarting the process. The runtime rebuilds its API client from
/// the updated config. `model` overrides the post-switch model
+85 -9
View File
@@ -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;
use crate::client::{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};
@@ -40,7 +40,10 @@ use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine};
use crate::core::events::Event as EngineEvent;
use crate::core::ops::Op;
use crate::hooks::HookEvent;
use crate::models::{ContentBlock, Message, SystemPrompt, context_window_for_model};
use crate::llm_client::LlmClient;
use crate::models::{
ContentBlock, Message, MessageRequest, SystemPrompt, Usage, context_window_for_model,
};
use crate::palette;
use crate::prompts;
use crate::session_manager::{
@@ -510,6 +513,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
mcp_config_path: config.mcp_config_path(),
skills_dir: app.skills_dir.clone(),
instructions: config.instructions_paths(),
project_context_pack_enabled: config.project_context_pack_enabled(),
// Effectively unlimited. V4 has a 1M context window and the user
// wants the model running until it's actually done. The previous cap
// of 100 hit the ceiling on long multi-step plans (wide refactors,
@@ -2981,6 +2985,50 @@ async fn fetch_available_models(config: &Config) -> Result<Vec<String>> {
Ok(ids)
}
async fn run_cache_warmup(app: &App, config: &Config) -> Result<Usage> {
let client = DeepSeekClient::new(config)?;
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: 1024,
system: app.system_prompt.clone(),
tools: None,
tool_choice: None,
metadata: None,
thinking: None,
reasoning_effort,
stream: None,
temperature: None,
top_p: None,
};
let warmup = build_cache_warmup_request(&request);
let response =
tokio::time::timeout(Duration::from_secs(45), client.create_message(warmup)).await??;
Ok(response.usage)
}
fn format_cache_warmup_result(usage: &Usage) -> String {
let cache = match (
usage.prompt_cache_hit_tokens,
usage.prompt_cache_miss_tokens,
) {
(Some(hit), Some(miss)) => format!("Cache warmup complete: hit {hit} | miss {miss}"),
(Some(hit), None) => format!("Cache warmup complete: hit {hit} | miss unavailable"),
(None, Some(miss)) => format!("Cache warmup complete: hit unavailable | miss {miss}"),
(None, None) => "Cache warmup complete: cache telemetry unavailable".to_string(),
};
format!(
"{cache}\nNote: the first warmup is usually a miss. Later requests that reuse the same stable prefix may hit the provider cache; a hit is not guaranteed."
)
}
fn format_available_models_message(current_model: &str, models: &[String]) -> String {
let mut lines = vec![format!("Available models ({})", models.len())];
for model in models {
@@ -3739,6 +3787,7 @@ async fn dispatch_user_message(
prompts::PromptSessionContext {
user_memory_block: None,
goal_objective: app.goal.goal_objective.as_deref(),
project_context_pack_enabled: config.project_context_pack_enabled(),
locale_tag: app.ui_locale.tag(),
},
),
@@ -4556,6 +4605,24 @@ async fn apply_command_result(
}
}
}
AppAction::CacheWarmup => {
app.status_message = Some("Warming DeepSeek cache...".to_string());
match run_cache_warmup(app, config).await {
Ok(usage) => {
let message = format_cache_warmup_result(&usage);
app.add_message(HistoryCell::System {
content: message.clone(),
});
app.status_message = Some("Cache warmup complete".to_string());
}
Err(error) => {
app.add_message(HistoryCell::System {
content: format!("Cache warmup failed: {error}"),
});
app.status_message = Some("Cache warmup failed".to_string());
}
}
}
AppAction::SwitchProvider { provider, model } => {
switch_provider(app, engine_handle, config, provider, model).await;
}
@@ -7002,9 +7069,15 @@ fn footer_coherence_spans(app: &App) -> Vec<Span<'static>> {
}
fn footer_cache_spans(app: &App) -> Vec<Span<'static>> {
let Some(hit_tokens) = app.session.last_prompt_cache_hit_tokens else {
if app.session.last_prompt_tokens.is_none() && app.session.last_completion_tokens.is_none() {
return Vec::new();
};
let Some(hit_tokens) = app.session.last_prompt_cache_hit_tokens else {
return vec![Span::styled(
"Cache: unavailable",
Style::default().fg(palette::TEXT_MUTED),
)];
};
let miss_tokens = app
.session
.last_prompt_cache_miss_tokens
@@ -7015,11 +7088,11 @@ fn footer_cache_spans(app: &App) -> Vec<Span<'static>> {
.saturating_sub(hit_tokens)
});
let total = hit_tokens.saturating_add(miss_tokens);
if total == 0 {
return Vec::new();
}
let percent = (f64::from(hit_tokens) / f64::from(total) * 100.0).clamp(0.0, 100.0);
let percent = if total == 0 {
0.0
} else {
(f64::from(hit_tokens) / f64::from(total) * 100.0).clamp(0.0, 100.0)
};
// Threshold-based coloring for cache hit rate (#396):
// >80%: green (good cache utilization)
// 40-80%: yellow/warning
@@ -7032,7 +7105,10 @@ fn footer_cache_spans(app: &App) -> Vec<Span<'static>> {
palette::STATUS_ERROR
};
vec![Span::styled(
format!("cache hit {:.0}%", percent),
format!(
"Cache: {:.1}% hit | hit {hit_tokens} | miss {miss_tokens}",
percent
),
Style::default().fg(color),
)]
}
+15 -4
View File
@@ -1483,11 +1483,22 @@ fn footer_auxiliary_spans_show_cache_when_compact() {
app.session.last_prompt_cache_miss_tokens = Some(12_000);
app.session.session_cost = 12.34;
let compact = spans_text(&footer_auxiliary_spans(&app, 14));
assert!(compact.contains("cache"));
let compact = spans_text(&footer_auxiliary_spans(&app, 48));
assert!(compact.contains("Cache: 75.0% hit"));
assert!(!compact.contains('$'));
}
#[test]
fn footer_auxiliary_spans_show_cache_unavailable_when_provider_omits_cache_fields() {
let mut app = create_test_app();
app.session.last_prompt_tokens = Some(48_000);
app.session.last_completion_tokens = Some(2_000);
let roomy = spans_text(&footer_auxiliary_spans(&app, 72));
assert!(roomy.contains("Cache: unavailable"));
}
#[test]
fn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {
let mut app = create_test_app();
@@ -1496,8 +1507,8 @@ fn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {
app.session.last_prompt_cache_miss_tokens = Some(12_000);
app.session.session_cost = 12.34;
let roomy = spans_text(&footer_auxiliary_spans(&app, 32));
assert!(roomy.contains("cache hit 75%"));
let roomy = spans_text(&footer_auxiliary_spans(&app, 72));
assert!(roomy.contains("Cache: 75.0% hit | hit 36000 | miss 12000"));
assert!(roomy.contains("$12.34"));
assert!(
!roomy.contains("ctx"),