#![allow(clippy::items_after_test_module)]
use std::time::Instant;
use super::CommandResult;
use crate::client::{CacheWarmupKey, PromptInspection, inspect_prompt_for_request};
use crate::compaction::estimate_input_tokens_conservative;
use crate::dependencies::{ExternalTool, Git};
use crate::localization::{Locale, MessageId, tr};
use crate::models::{ContentBlock, MessageRequest, SystemPrompt, context_window_for_model};
use crate::tui::app::{App, AppAction, TurnCacheRecord};
use crate::tui::history::HistoryCell;
fn token_count(value: Option<u32>, locale: Locale) -> String {
value.map_or_else(
|| tr(locale, MessageId::CmdTokensNotReported).to_string(),
|tokens| tokens.to_string(),
)
}
fn active_context_summary(app: &App, locale: Locale) -> String {
let estimated =
estimate_input_tokens_conservative(&app.api_messages, app.system_prompt.as_ref());
match context_window_for_model(&app.model) {
Some(window) => {
let used = estimated.min(window as usize);
let percent = (used as f64 / f64::from(window) * 100.0).clamp(0.0, 100.0);
tr(locale, MessageId::CmdTokensContextWithWindow)
.replace("{used}", &used.to_string())
.replace("{window}", &window.to_string())
.replace("{percent}", &format!("{percent:.1}"))
}
None => tr(locale, MessageId::CmdTokensContextUnknownWindow)
.replace("{estimated}", &estimated.to_string()),
}
}
fn cache_summary(app: &App, locale: Locale) -> String {
match (
app.session.last_prompt_cache_hit_tokens,
app.session.last_prompt_cache_miss_tokens,
) {
(Some(hit), Some(miss)) => tr(locale, MessageId::CmdTokensCacheBoth)
.replace("{hit}", &hit.to_string())
.replace("{miss}", &miss.to_string()),
(Some(hit), None) => {
tr(locale, MessageId::CmdTokensCacheHitOnly).replace("{hit}", &hit.to_string())
}
(None, Some(miss)) => {
tr(locale, MessageId::CmdTokensCacheMissOnly).replace("{miss}", &miss.to_string())
}
(None, None) => tr(locale, MessageId::CmdTokensNotReported).to_string(),
}
}
pub fn tokens(app: &mut App) -> CommandResult {
let locale = app.ui_locale;
let message_count = app.api_messages.len();
let chat_count = app.history.len();
let report = tr(locale, MessageId::CmdTokensReport)
.replace("{active}", &active_context_summary(app, locale))
.replace(
"{input}",
&token_count(app.session.last_prompt_tokens, locale),
)
.replace(
"{output}",
&token_count(app.session.last_completion_tokens, locale),
)
.replace("{cache}", &cache_summary(app, locale))
.replace("{total}", &app.session.total_tokens.to_string())
.replace(
"{cost}",
&app.format_cost_amount_precise(
app.displayed_session_cost_for_currency(app.cost_currency),
),
)
.replace("{api_messages}", &message_count.to_string())
.replace("{chat_messages}", &chat_count.to_string())
.replace("{model}", &app.model);
CommandResult::message(report)
}
pub fn cost(app: &mut App) -> CommandResult {
let total = app.displayed_session_cost_for_currency(app.cost_currency);
let report = tr(app.ui_locale, MessageId::CmdCostReport)
.replace("{cost}", &app.format_cost_amount_precise(total));
CommandResult::message(report)
}
pub fn system_prompt(app: &mut App) -> CommandResult {
let prompt_text = match &app.system_prompt {
Some(SystemPrompt::Text(text)) => text.clone(),
Some(SystemPrompt::Blocks(blocks)) => blocks
.iter()
.map(|b| b.text.clone())
.collect::<Vec<_>>()
.join("\n\n---\n\n"),
None => "(no system prompt)".to_string(),
};
let display = if prompt_text.len() > 500 {
let truncate_at = prompt_text
.char_indices()
.take_while(|(i, _)| *i <= 500)
.last()
.map_or(0, |(i, _)| i);
format!(
"{}...\n\n(truncated, {} chars total)",
&prompt_text[..truncate_at],
prompt_text.len()
)
} else {
prompt_text
};
CommandResult::message(format!(
"System Prompt ({} mode):\n─────────────────────────────\n{}",
app.mode.label(),
display
))
}
pub fn context(_app: &mut App) -> CommandResult {
CommandResult::action(AppAction::OpenContextInspector)
}
pub fn cache(app: &mut App, arg: Option<&str>) -> CommandResult {
let arg = arg.map(str::trim).filter(|s| !s.is_empty());
if let Some(flags) = arg.and_then(|a| a.strip_prefix("inspect")) {
let flags = flags.trim();
let verbose = flags.split_whitespace().any(|flag| flag == "--verbose");
let json_mode = flags.split_whitespace().any(|flag| flag == "--json");
return CommandResult::message(format_cache_inspect(app, verbose, json_mode));
}
if matches!(arg, Some("warmup")) {
return CommandResult::action(AppAction::CacheWarmup);
}
if matches!(arg, Some("stats")) {
return CommandResult::message(format_cache_stats(app));
}
if matches!(arg, Some("zones")) {
return CommandResult::message(format_cache_zones(app));
}
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)
.min(crate::tui::app::App::TURN_CACHE_HISTORY_CAP);
if cap == 0 {
return CommandResult::message(tr(app.ui_locale, MessageId::CmdCacheNoData));
}
CommandResult::message(format_cache_history(app, count, app.ui_locale))
}
fn format_cache_inspect(app: &mut App, verbose: bool, json_mode: bool) -> String {
if verbose && json_mode {
return "cache inspect: --json and --verbose cannot be combined".to_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: app.session.last_tool_catalog.clone(),
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 current_warmup_key = CacheWarmupKey::from_inspection(
&format!("{:?}", app.api_provider),
&app.model,
app.session.last_base_url.as_deref().unwrap_or_default(),
&inspection,
);
let warmup_status =
format_warmup_status(app.session.last_warmup_key.as_ref(), ¤t_warmup_key);
if json_mode {
let output = serde_json::to_value(&inspection)
.and_then(|mut value| {
if let serde_json::Value::Object(ref mut object) = value {
object.insert(
"current_warmup_key".to_string(),
serde_json::to_value(¤t_warmup_key)?,
);
object.insert(
"warmup_status".to_string(),
serde_json::Value::String(warmup_status.trim_end().to_string()),
);
}
serde_json::to_string_pretty(&value)
})
.unwrap_or_else(|_| {
"{\"error\":\"cache inspection serialization failed\"}".to_string()
});
app.session.last_cache_inspection = Some(inspection);
return output;
}
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!(
"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_static_prefix_status(previous, &inspection));
out.push_str(&format_first_divergence(previous, &inspection));
out.push_str(&warmup_status);
let total_tokens: usize = inspection
.layers
.iter()
.map(|layer| layer.token_estimate)
.sum();
out.push_str(&format!("Estimated reusable tokens: ~{total_tokens}\n"));
out.push('\n');
for layer in &inspection.layers {
let mut line = format!(
"{}: {}, 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",
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);
}
if verbose {
out.push_str("\nVerbose diff\n");
if let Some(previous) = previous {
out.push_str(&format_verbose_diff(previous, &inspection));
} else {
out.push_str("No previous inspection to compare against.\n");
}
}
app.session.last_cache_inspection = Some(inspection);
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(previous) if previous == current => {
format!(
"Warmup status: valid (key {} matches)\n",
current.hash_short()
)
}
Some(previous) => {
let mut reasons = Vec::new();
if previous.provider != current.provider {
reasons.push("provider changed");
}
if previous.model != current.model {
reasons.push("model changed");
}
if previous.base_url != current.base_url {
reasons.push("base URL changed");
}
if previous.static_prefix_hash != current.static_prefix_hash {
reasons.push("static prefix changed");
}
if previous.tool_catalog_hash != current.tool_catalog_hash {
reasons.push("tool catalog changed");
}
if previous.project_pack_hash != current.project_pack_hash {
reasons.push("project pack changed");
}
if previous.skills_hash != current.skills_hash {
reasons.push("skills changed");
}
let reason_text = if reasons.is_empty() {
"unknown prefix input changed".to_string()
} else {
reasons.join(", ")
};
format!(
"Warmup status: invalid ({} -> {}; {})\n",
previous.hash_short(),
current.hash_short(),
reason_text
)
}
}
}
fn format_verbose_diff(previous: &PromptInspection, current: &PromptInspection) -> String {
let mut out = String::new();
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 == curr => {
out.push_str(&format!(" [{index}] {} unchanged\n", curr.name));
}
(Some(prev), Some(curr)) => {
out.push_str(&format!(" [{index}] {} changed\n", curr.name));
if prev.name != curr.name {
out.push_str(&format!(" name: {} -> {}\n", prev.name, curr.name));
}
if prev.stability != curr.stability {
out.push_str(&format!(
" stability: {} -> {}\n",
prev.stability.label(),
curr.stability.label()
));
}
if prev.char_len != curr.char_len {
out.push_str(&format!(
" chars: {} -> {} ({:+})\n",
prev.char_len,
curr.char_len,
curr.char_len as i64 - prev.char_len as i64
));
}
if prev.sha256 != curr.sha256 {
out.push_str(&format!(
" hash: {} -> {}\n",
short_hash(&prev.sha256),
short_hash(&curr.sha256)
));
}
}
(None, Some(curr)) => {
out.push_str(&format!(" [{index}] {} added\n", curr.name));
}
(Some(prev), None) => {
out.push_str(&format!(" [{index}] {} removed\n", prev.name));
}
(None, None) => unreachable!("index is within max_len"),
}
}
out
}
fn short_hash(hash: &str) -> &str {
&hash[..hash.len().min(12)]
}
fn format_cache_stats(app: &App) -> String {
let mut out = String::new();
out.push_str("Cache Stats\n");
out.push_str("\n── Prefix Stability\n");
match app.prefix_stability_pct {
Some(pct) => {
let checks = app.prefix_checks_total;
let changes = app.prefix_change_count;
let stable_checks = checks.saturating_sub(changes);
if changes == 0 {
out.push_str(&format!(
" Stability: {pct}% ({stable_checks}/{checks} checks)\n"
));
out.push_str(" Status: stable (no prefix changes this session)\n");
} else {
out.push_str(&format!(
" Stability: {pct}% ({stable_checks}/{checks} checks, {changes} change{})\n",
if changes == 1 { "" } else { "s" }
));
out.push_str(" Status: WARNING — prefix has changed\n");
if let Some(ref desc) = app.last_prefix_change_desc {
out.push_str(&format!(" Last change: {desc}\n"));
}
}
}
None => {
out.push_str(" Stability: unknown (no checks recorded yet)\n");
out.push_str(" Run a turn first to collect prefix stability data.\n");
}
}
out.push_str("\n── Prefix Fingerprint\n");
match &app.last_pinned_prefix_hash {
Some(hash) => {
out.push_str(&format!(" Pinned hash: {hash}\n"));
let short = if hash.len() >= 12 { &hash[..12] } else { hash };
out.push_str(&format!(" Short id: {short}\n"));
if app.prefix_change_count > 0 {
out.push_str(" Drift: WARNING — hash has changed during this session\n");
out.push_str(&format!(
" ({change} change{plural} detected)\n",
change = app.prefix_change_count,
plural = if app.prefix_change_count == 1 {
""
} else {
"s"
}
));
} else {
out.push_str(" Drift: none (hash stable)\n");
}
}
None => {
out.push_str(" Pinned hash: unavailable\n");
out.push_str(" Run a turn first, or use /cache inspect.\n");
}
}
out.push_str("\n── Cache Hit Rate\n");
let history = &app.session.turn_cache_history;
if history.is_empty() {
out.push_str(" No turn telemetry recorded yet.\n");
} else {
let mut turns = 0u64;
let (hit, miss, input) = app.session.turn_cache_history.iter().fold(
(0u64, 0u64, 0u64),
|(hit, miss, input), rec| {
let Some(hit_tokens) = rec.cache_hit_tokens else {
return (hit, miss, input);
};
let h = u64::from(hit_tokens);
let m = u64::from(
rec.cache_miss_tokens
.unwrap_or(rec.input_tokens.saturating_sub(hit_tokens)),
);
turns += 1;
(hit + h, miss + m, input + u64::from(rec.input_tokens))
},
);
let total_cache = hit + miss;
let avg_pct = if total_cache > 0 {
(hit as f64 / total_cache as f64 * 100.0).clamp(0.0, 100.0)
} else {
0.0
};
out.push_str(&format!(" Turns recorded: {turns}\n"));
out.push_str(&format!(
" Cache hit tokens: {hit} ({avg_pct:.1}% of {total_cache} cache-aware tokens)\n",
hit = format_tokens(hit),
total_cache = format_tokens(total_cache),
));
out.push_str(&format!(
" Cache miss tokens: {miss}\n",
miss = format_tokens(miss),
));
out.push_str(&format!(
" Total input tokens: {input}\n",
input = format_tokens(input),
));
if avg_pct < 80.0 {
out.push_str(" NOTE: cache hit rate is low (< 80%). Check prefix stability above or consider /compact.\n");
}
}
out
}
fn format_cache_zones(app: &App) -> String {
let mut out = String::new();
out.push_str("Cache Zones (#2264 three-zone contract, Phase 1 foundation)\n");
out.push_str("\n── PinnedPrefix (system + tools, frozen baseline)\n");
match &app.last_pinned_prefix_hash {
Some(hash) => {
let short = if hash.len() >= 12 { &hash[..12] } else { hash };
out.push_str(&format!(" Short id: {short}\n"));
if app.prefix_change_count > 0 {
out.push_str(&format!(
" Status: WARNING — {change} drift{plural} detected\n",
change = app.prefix_change_count,
plural = if app.prefix_change_count == 1 {
""
} else {
"s"
}
));
} else {
out.push_str(" Status: stable (no drift this session)\n");
}
if let Some(pct) = app.prefix_stability_pct {
out.push_str(&format!(" Stability: {pct}%\n"));
}
}
None => {
out.push_str(" Status: unavailable (not yet frozen)\n");
out.push_str(" Run a turn first to freeze the baseline.\n");
}
}
out.push_str("\n── AppendLog (conversation history, append-only)\n");
out.push_str(" Status: Phase 1 scaffolding — not yet wired into engine\n");
let msg_count = app.api_messages.len();
out.push_str(&format!(" Messages: {msg_count}\n"));
let history_count = app
.api_messages
.iter()
.filter(|m| m.role != "system")
.count();
out.push_str(&format!(" History msgs: {history_count}\n"));
out.push_str("\n── TurnScratch (per-turn ephemeral data)\n");
out.push_str(" Status: Phase 1 scaffolding — not yet wired into engine\n");
out.push_str("\n── Contract Status\n");
let has_drift = app.prefix_change_count > 0;
out.push_str(&format!(
" PinnedPrefix: {}\n",
if app.last_pinned_prefix_hash.is_some() {
if has_drift {
"WARNING — drifted"
} else {
"OK"
}
} else {
"not frozen"
}
));
out.push_str(" AppendLog: Phase 1 foundation\n");
out.push_str(" TurnScratch: Phase 1 foundation\n");
out
}
fn format_tokens(n: u64) -> String {
if n >= 1_000_000 {
format!("{:.1}M", n as f64 / 1_000_000.0)
} else if n >= 1_000 {
format!("{:.1}K", n as f64 / 1_000.0)
} else {
n.to_string()
}
}
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);
let rows: Vec<&TurnCacheRecord> = app.session.turn_cache_history.iter().skip(start).collect();
let mut totals_input: u64 = 0;
let mut totals_hit: u64 = 0;
let mut totals_miss: u64 = 0;
let mut header = tr(locale, MessageId::CmdCacheHeader)
.replace("{count}", &rows.len().to_string())
.replace("{total}", &total.to_string())
.replace("{model}", &app.model);
header.push_str(&"─".repeat(76));
header.push('\n');
header.push_str("turn in out hit miss replay ratio age\n");
header.push_str(&"─".repeat(76));
header.push('\n');
let now = Instant::now();
let mut body = String::new();
let absolute_start = total.saturating_sub(rows.len());
for (i, rec) in rows.iter().enumerate() {
let turn_index = absolute_start + i + 1;
totals_input += u64::from(rec.input_tokens);
let replay_cell = rec
.reasoning_replay_tokens
.map_or_else(|| "—".to_string(), |t| t.to_string());
let age = humanize_age(now.saturating_duration_since(rec.recorded_at));
let Some(hit) = rec.cache_hit_tokens else {
body.push_str(&format!(
"{turn:>4} {input:>5} {output:>5} {hit:>5} {miss:>5} {replay:>6} {ratio:>6} {age}\n",
turn = turn_index,
input = rec.input_tokens,
output = rec.output_tokens,
hit = "—",
miss = "—",
replay = replay_cell,
ratio = "—",
age = age,
));
continue;
};
let miss_reported = rec.cache_miss_tokens;
let miss = miss_reported.unwrap_or_else(|| rec.input_tokens.saturating_sub(hit));
let accounted = u64::from(hit) + u64::from(miss);
let ratio = if accounted == 0 {
" —".to_string()
} else {
format!("{:>5.1}%", 100.0 * f64::from(hit) / accounted as f64)
};
totals_hit += u64::from(hit);
totals_miss += u64::from(miss);
let miss_cell = match miss_reported {
Some(_) => format!("{miss}"),
None => format!("{miss}*"),
};
body.push_str(&format!(
"{turn:>4} {input:>5} {output:>5} {hit:>5} {miss:>5} {replay:>6} {ratio} {age}\n",
turn = turn_index,
input = rec.input_tokens,
output = rec.output_tokens,
hit = hit,
miss = miss_cell,
replay = replay_cell,
ratio = ratio,
age = age,
));
}
let totals_accounted = totals_hit + totals_miss;
let avg_ratio = if totals_accounted == 0 {
"—".to_string()
} else {
format!(
"{:.1}%",
100.0 * totals_hit as f64 / totals_accounted as f64
)
};
let mut footer = String::new();
footer.push_str(&"─".repeat(76));
footer.push('\n');
footer.push_str(
&tr(locale, MessageId::CmdCacheTotals)
.replace("{sum_in}", &totals_input.to_string())
.replace("{sum_hit}", &totals_hit.to_string())
.replace("{sum_miss}", &totals_miss.to_string())
.replace("{avg}", &avg_ratio),
);
footer.push_str(tr(locale, MessageId::CmdCacheFootnote));
footer.push_str(tr(locale, MessageId::CmdCacheAdvice));
format!("{header}{body}{footer}")
}
fn humanize_age(d: std::time::Duration) -> String {
let secs = d.as_secs();
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m{:02}s", secs / 60, secs % 60)
} else {
format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::models::{ContentBlock, Message, SystemBlock, Tool};
use crate::tui::app::{App, TuiOptions};
use crate::tui::history::{GenericToolCell, ToolCell, ToolStatus};
use std::path::PathBuf;
fn create_test_app() -> App {
let options = TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: PathBuf::from("/tmp/test-workspace"),
config_path: None,
config_profile: None,
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
use_bracketed_paste: true,
max_subagents: 1,
skills_dir: PathBuf::from("/tmp/test-skills"),
memory_path: PathBuf::from("memory.md"),
notes_path: PathBuf::from("notes.txt"),
mcp_config_path: PathBuf::from("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: true,
yolo: false,
resume_session_id: None,
initial_input: None,
};
let mut app = App::new(options, &Config::default());
app.ui_locale = crate::localization::Locale::En;
app.cost_currency = crate::pricing::CostCurrency::Usd;
app.api_provider = crate::config::ApiProvider::Deepseek;
app
}
fn test_tool(name: &str) -> Tool {
Tool {
tool_type: Some("function".to_string()),
name: name.to_string(),
description: format!("{name} test tool"),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"path": {"type": "string"}
}
}),
allowed_callers: None,
defer_loading: Some(false),
input_examples: None,
strict: Some(true),
cache_control: None,
}
}
#[test]
fn test_tokens_shows_usage_info() {
let mut app = create_test_app();
app.session.total_tokens = 1234;
app.session.session_cost = 0.05;
app.session.last_prompt_tokens = Some(100);
app.session.last_completion_tokens = Some(25);
app.session.last_prompt_cache_hit_tokens = Some(70);
app.session.last_prompt_cache_miss_tokens = Some(30);
app.api_messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "test".to_string(),
cache_control: None,
}],
});
app.history.push(HistoryCell::User {
content: "test".to_string(),
});
let result = tokens(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Token Usage"));
assert!(msg.contains("Active context:"));
assert!(msg.contains("Last API input:"));
assert!(msg.contains("Last API output:"));
assert!(msg.contains("Cache hit/miss:"));
assert!(msg.contains("70 hit / 30 miss"));
assert!(msg.contains("Cumulative tokens:"));
assert!(msg.contains("Approx session cost:"));
assert!(msg.contains("API messages:"));
assert!(msg.contains("Chat messages:"));
assert!(msg.contains("Model:"));
}
#[test]
fn test_cost_shows_spending_info() {
let mut app = create_test_app();
app.session.session_cost = 0.1234;
let result = cost(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Session Cost"));
assert!(msg.contains("Approx total spent:"));
assert!(msg.contains("approximate"));
assert!(msg.contains("$0.1234"));
}
#[test]
fn test_system_prompt_displays_text() {
let mut app = create_test_app();
app.system_prompt = Some(SystemPrompt::Text("Test system prompt".to_string()));
let result = system_prompt(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("System Prompt"));
assert!(msg.contains("Test system prompt"));
}
#[test]
fn test_system_prompt_displays_blocks() {
let mut app = create_test_app();
app.system_prompt = Some(SystemPrompt::Blocks(vec![
SystemBlock {
block_type: "text".to_string(),
text: "Block 1".to_string(),
cache_control: None,
},
SystemBlock {
block_type: "text".to_string(),
text: "Block 2".to_string(),
cache_control: None,
},
]));
let result = system_prompt(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("System Prompt"));
assert!(msg.contains("Block 1"));
assert!(msg.contains("Block 2"));
}
#[test]
fn test_system_prompt_none() {
let mut app = create_test_app();
app.system_prompt = None;
let result = system_prompt(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("(no system prompt)"));
}
#[test]
fn test_system_prompt_truncates_long_text() {
let mut app = create_test_app();
let long_text = "x".repeat(600);
app.system_prompt = Some(SystemPrompt::Text(long_text));
let result = system_prompt(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("..."));
assert!(msg.contains("chars total"));
}
#[test]
fn cache_command_reports_no_data_before_first_turn() {
let mut app = create_test_app();
let result = cache(&mut app, None);
let msg = result.message.expect("cache produces a message");
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_uses_last_request_tool_catalog() {
let mut app = create_test_app();
app.system_prompt = Some(SystemPrompt::Text("Base policy".to_string()));
app.session.last_tool_catalog = Some(vec![test_tool("read_file")]);
app.api_messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "Current task".to_string(),
cache_control: None,
}],
});
let msg = cache(&mut app, Some("inspect"))
.message
.expect("inspect output");
assert!(msg.contains("Tool catalog hash: "), "got: {msg}");
assert!(!msg.contains("(no tools registered)"), "got: {msg}");
assert!(msg.contains("Tool catalog: static"), "got: {msg}");
assert!(msg.contains("bytes="), "got: {msg}");
assert!(msg.contains("~"), "got: {msg}");
}
#[test]
fn cache_inspect_json_reports_tool_catalog_hash_and_layer_sizes() {
let mut app = create_test_app();
app.system_prompt = Some(SystemPrompt::Text("Base policy".to_string()));
app.session.last_tool_catalog = Some(vec![test_tool("read_file")]);
app.api_messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "Current task".to_string(),
cache_control: None,
}],
});
let msg = cache(&mut app, Some("inspect --json"))
.message
.expect("inspect json output");
let parsed: serde_json::Value = serde_json::from_str(&msg).expect("valid json");
assert_eq!(parsed["tool_catalog_hash"].as_str().unwrap().len(), 64);
assert!(
parsed["warmup_status"]
.as_str()
.is_some_and(|status| status.starts_with("Warmup status: no previous warmup"))
);
assert!(parsed["current_warmup_key"].is_object());
let tool_layer = parsed["layers"]
.as_array()
.unwrap()
.iter()
.find(|layer| layer["name"] == "Tool catalog")
.expect("tool catalog layer");
assert!(tool_layer["byte_len"].as_u64().unwrap() > 0);
assert!(tool_layer["token_estimate"].as_u64().unwrap() > 0);
}
fn warmup_key(model: &str, static_hash: &str) -> CacheWarmupKey {
CacheWarmupKey {
provider: "Deepseek".to_string(),
model: model.to_string(),
base_url: "https://api.deepseek.com".to_string(),
static_prefix_hash: static_hash.to_string(),
tool_catalog_hash: "tool".to_string(),
project_pack_hash: "project".to_string(),
skills_hash: "skills".to_string(),
}
}
#[test]
fn warmup_status_reports_valid_matching_key() {
let key = warmup_key("deepseek-v4-pro", "static-a");
let result = format_warmup_status(Some(&key), &key);
assert!(result.contains("Warmup status: valid"), "got: {result}");
}
#[test]
fn warmup_status_reports_invalidation_reason() {
let previous = warmup_key("deepseek-v4-pro", "static-a");
let current = warmup_key("deepseek-v4-flash", "static-b");
let result = format_warmup_status(Some(&previous), ¤t);
assert!(result.contains("Warmup status: invalid"), "got: {result}");
assert!(result.contains("model changed"), "got: {result}");
assert!(result.contains("static prefix changed"), "got: {result}");
}
#[test]
fn warmup_status_reports_project_and_skills_reasons() {
let previous = warmup_key("deepseek-v4-pro", "static-a");
let mut current = previous.clone();
current.project_pack_hash = "project-b".to_string();
current.skills_hash = "skills-b".to_string();
let result = format_warmup_status(Some(&previous), ¤t);
assert!(result.contains("project pack changed"), "got: {result}");
assert!(result.contains("skills changed"), "got: {result}");
assert!(!result.contains("; )"), "got: {result}");
}
#[test]
fn cache_inspect_rejects_json_verbose_combo() {
let mut app = create_test_app();
let msg = cache(&mut app, Some("inspect --json --verbose"))
.message
.expect("inspect output");
assert_eq!(
msg,
"cache inspect: --json and --verbose cannot be combined"
);
}
#[test]
fn cache_inspect_json_uses_cjk_aware_token_estimate() {
let mut app = create_test_app();
app.system_prompt = Some(SystemPrompt::Text("缓存命中测试".to_string()));
let msg = cache(&mut app, Some("inspect --json"))
.message
.expect("inspect json output");
let parsed: serde_json::Value = serde_json::from_str(&msg).expect("valid json");
let system_layer = parsed["layers"]
.as_array()
.unwrap()
.iter()
.find(|layer| layer["name"] == "Global system prefix")
.expect("system layer");
assert_eq!(
system_layer["token_estimate"].as_u64(),
system_layer["char_len"].as_u64()
);
}
#[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 _spill_guard = crate::tools::truncate::TEST_SPILLOVER_GUARD
.lock()
.unwrap_or_else(|err| err.into_inner());
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();
let now = Instant::now();
app.push_turn_cache_record(TurnCacheRecord {
input_tokens: 4_000,
output_tokens: 200,
cache_hit_tokens: Some(3_000),
cache_miss_tokens: Some(1_000),
reasoning_replay_tokens: None,
recorded_at: now,
});
app.push_turn_cache_record(TurnCacheRecord {
input_tokens: 6_000,
output_tokens: 250,
cache_hit_tokens: Some(3_000),
cache_miss_tokens: Some(3_000),
reasoning_replay_tokens: Some(150),
recorded_at: now,
});
app.push_turn_cache_record(TurnCacheRecord {
input_tokens: 5_000,
output_tokens: 100,
cache_hit_tokens: Some(2_500),
cache_miss_tokens: None,
reasoning_replay_tokens: None,
recorded_at: now,
});
app.push_turn_cache_record(TurnCacheRecord {
input_tokens: 1_000,
output_tokens: 50,
cache_hit_tokens: None,
cache_miss_tokens: None,
reasoning_replay_tokens: None,
recorded_at: now,
});
let result = cache(&mut app, None);
let msg = result.message.expect("cache produces a message");
assert!(msg.contains("last 4 of 4 turn(s)"), "got: {msg}");
assert!(msg.contains("75.0%"), "got: {msg}");
assert!(msg.contains("50.0%"), "got: {msg}");
assert!(msg.contains("2500*"), "got: {msg}");
assert!(msg.contains("avg hit ratio: 56.7%"), "got: {msg}");
assert!(msg.contains("70%"), "got: {msg}");
}
#[test]
fn cache_command_count_argument_clamps_to_history() {
let mut app = create_test_app();
for _ in 0..3 {
app.push_turn_cache_record(TurnCacheRecord {
input_tokens: 1_000,
output_tokens: 100,
cache_hit_tokens: Some(500),
cache_miss_tokens: Some(500),
reasoning_replay_tokens: None,
recorded_at: Instant::now(),
});
}
let result = cache(&mut app, Some("100"));
let msg = result.message.expect("cache produces a message");
assert!(msg.contains("last 3 of 3 turn(s)"), "got: {msg}");
}
#[test]
fn turn_cache_history_is_capped_at_50() {
let mut app = create_test_app();
for i in 0..(crate::tui::app::App::TURN_CACHE_HISTORY_CAP + 12) {
app.push_turn_cache_record(TurnCacheRecord {
input_tokens: i as u32,
output_tokens: 1,
cache_hit_tokens: Some(i as u32),
cache_miss_tokens: Some(0),
reasoning_replay_tokens: None,
recorded_at: Instant::now(),
});
}
assert_eq!(
app.session.turn_cache_history.len(),
crate::tui::app::App::TURN_CACHE_HISTORY_CAP
);
assert_eq!(
app.session.turn_cache_history.back().unwrap().input_tokens,
(crate::tui::app::App::TURN_CACHE_HISTORY_CAP + 11) as u32
);
}
#[test]
fn test_context_shows_usage_stats() {
let mut app = create_test_app();
app.api_messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "Hello".to_string(),
cache_control: None,
}],
});
app.history.push(HistoryCell::User {
content: "Hello".to_string(),
});
let result = context(&mut app);
assert!(matches!(
result.action,
Some(AppAction::OpenContextInspector)
));
assert!(result.message.is_none());
}
#[test]
fn test_undo_conversation_removes_last_exchange() {
let mut app = create_test_app();
app.history.push(HistoryCell::User {
content: "Hello".to_string(),
});
app.history.push(HistoryCell::Assistant {
content: "Hi".to_string(),
streaming: false,
});
app.api_messages.push(Message {
role: "user".to_string(),
content: vec![],
});
app.api_messages.push(Message {
role: "assistant".to_string(),
content: vec![],
});
let initial_history_len = app.history.len();
let initial_api_len = app.api_messages.len();
let result = undo_conversation(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Removed"));
assert!(app.history.len() < initial_history_len);
assert!(app.api_messages.len() < initial_api_len);
}
#[test]
fn test_undo_conversation_nothing_to_undo() {
let mut app = create_test_app();
app.history.clear();
app.api_messages.clear();
let result = undo_conversation(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Nothing to undo") || msg.contains("Removed"));
}
#[test]
fn test_retry_with_previous_message() {
let mut app = create_test_app();
app.history.push(HistoryCell::User {
content: "Test message".to_string(),
});
app.history.push(HistoryCell::Assistant {
content: "Response".to_string(),
streaming: false,
});
let result = retry(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Retrying"));
assert!(msg.contains("Test message"));
assert!(matches!(result.action, Some(AppAction::SendMessage(_))));
}
#[test]
fn test_retry_no_previous_message() {
let mut app = create_test_app();
let result = retry(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("No previous request to retry"));
assert!(result.action.is_none());
}
#[test]
fn test_retry_truncates_long_input() {
let mut app = create_test_app();
let long_input = "x".repeat(100);
app.history.push(HistoryCell::User {
content: long_input.clone(),
});
app.history.push(HistoryCell::Assistant {
content: "Response".to_string(),
streaming: false,
});
let result = retry(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Retrying"));
assert!(msg.contains("..."));
}
#[test]
fn test_patch_undo_requests_session_resync_after_restore() {
use crate::snapshot::SnapshotRepo;
use crate::test_support::lock_test_env;
use std::sync::MutexGuard;
use tempfile::tempdir;
struct HomeGuard {
prev: Option<std::ffi::OsString>,
_lock: MutexGuard<'static, ()>,
}
impl Drop for HomeGuard {
fn drop(&mut self) {
unsafe {
match self.prev.take() {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
}
}
}
fn scoped_home(home: &std::path::Path) -> HomeGuard {
let lock = lock_test_env();
let prev = std::env::var_os("HOME");
unsafe {
std::env::set_var("HOME", home);
}
HomeGuard { prev, _lock: lock }
}
let tmp = tempdir().unwrap();
let workspace = tmp.path().join("ws");
std::fs::create_dir_all(&workspace).unwrap();
let _guard = scoped_home(tmp.path());
let repo = SnapshotRepo::open_or_init(&workspace).unwrap();
std::fs::write(workspace.join("a.txt"), b"original").unwrap();
repo.snapshot("pre-turn:1").unwrap();
std::fs::write(workspace.join("a.txt"), b"modified").unwrap();
repo.snapshot("post-turn:1").unwrap();
let mut app = create_test_app();
app.workspace = workspace.clone();
app.api_messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "please edit a.txt".to_string(),
cache_control: None,
}],
});
let result = patch_undo(&mut app);
assert!(!result.is_error);
assert!(matches!(
result.action,
Some(AppAction::SyncSession {
ref messages,
ref workspace,
..
}) if messages == &app.api_messages && workspace == &app.workspace
));
}
#[test]
fn test_patch_undo_walks_back_to_older_snapshot_on_repeat() {
use crate::snapshot::SnapshotRepo;
use crate::test_support::lock_test_env;
use std::sync::MutexGuard;
use tempfile::tempdir;
struct HomeGuard {
prev: Option<std::ffi::OsString>,
_lock: MutexGuard<'static, ()>,
}
impl Drop for HomeGuard {
fn drop(&mut self) {
unsafe {
match self.prev.take() {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
}
}
}
fn scoped_home(home: &std::path::Path) -> HomeGuard {
let lock = lock_test_env();
let prev = std::env::var_os("HOME");
unsafe {
std::env::set_var("HOME", home);
}
HomeGuard { prev, _lock: lock }
}
let tmp = tempdir().unwrap();
let workspace = tmp.path().join("ws");
std::fs::create_dir_all(&workspace).unwrap();
let _guard = scoped_home(tmp.path());
let repo = SnapshotRepo::open_or_init(&workspace).unwrap();
let file = workspace.join("a.txt");
std::fs::write(&file, b"zero").unwrap();
repo.snapshot("tool:first").unwrap();
std::fs::write(&file, b"one").unwrap();
repo.snapshot("tool:second").unwrap();
std::fs::write(&file, b"two").unwrap();
let mut app = create_test_app();
app.workspace = workspace.clone();
let first = patch_undo(&mut app);
assert!(!first.is_error);
assert_eq!(std::fs::read_to_string(&file).unwrap(), "one");
let second = patch_undo(&mut app);
assert!(!second.is_error);
assert_eq!(std::fs::read_to_string(&file).unwrap(), "zero");
}
#[test]
fn test_patch_undo_prunes_tool_turn_context() {
use crate::snapshot::SnapshotRepo;
use crate::test_support::lock_test_env;
use std::sync::MutexGuard;
use tempfile::tempdir;
struct HomeGuard {
prev: Option<std::ffi::OsString>,
_lock: MutexGuard<'static, ()>,
}
impl Drop for HomeGuard {
fn drop(&mut self) {
unsafe {
match self.prev.take() {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
}
}
}
fn scoped_home(home: &std::path::Path) -> HomeGuard {
let lock = lock_test_env();
let prev = std::env::var_os("HOME");
unsafe {
std::env::set_var("HOME", home);
}
HomeGuard { prev, _lock: lock }
}
let tmp = tempdir().unwrap();
let workspace = tmp.path().join("ws");
std::fs::create_dir_all(&workspace).unwrap();
let _guard = scoped_home(tmp.path());
let repo = SnapshotRepo::open_or_init(&workspace).unwrap();
let file = workspace.join("a.txt");
std::fs::write(&file, b"alpha").unwrap();
repo.snapshot("tool:call-1").unwrap();
std::fs::write(&file, b"alpha-fixed").unwrap();
let mut app = create_test_app();
app.workspace = workspace.clone();
app.history.push(HistoryCell::User {
content: "please edit a.txt".to_string(),
});
app.history.push(HistoryCell::Assistant {
content: "I will update the file.".to_string(),
streaming: false,
});
app.history
.push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: "write_file".to_string(),
status: ToolStatus::Success,
input_summary: Some("a.txt".to_string()),
output: Some("updated".to_string()),
prompts: None,
spillover_path: None,
output_summary: None,
is_diff: false,
})));
app.history.push(HistoryCell::Assistant {
content: "Done, file is fixed now.".to_string(),
streaming: false,
});
app.tool_cells.insert("call-1".to_string(), 2);
app.api_messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "please edit a.txt".to_string(),
cache_control: None,
}],
});
app.api_messages.push(Message {
role: "assistant".to_string(),
content: vec![
ContentBlock::Text {
text: "I will update the file.".to_string(),
cache_control: None,
},
ContentBlock::ToolUse {
id: "call-1".to_string(),
name: "write_file".to_string(),
input: serde_json::json!({"path": "a.txt"}),
caller: None,
},
],
});
app.api_messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: "call-1".to_string(),
content: "updated".to_string(),
is_error: None,
content_blocks: None,
}],
});
app.api_messages.push(Message {
role: "assistant".to_string(),
content: vec![ContentBlock::Text {
text: "Done, file is fixed now.".to_string(),
cache_control: None,
}],
});
let result = patch_undo(&mut app);
assert!(!result.is_error);
assert_eq!(std::fs::read_to_string(&file).unwrap(), "alpha");
assert_eq!(app.history.len(), 3);
assert!(matches!(
app.history.last(),
Some(HistoryCell::System { content }) if content.contains("/undo reverted workspace")
));
assert_eq!(app.api_messages.len(), 2);
assert!(matches!(
&app.api_messages[0].content[0],
ContentBlock::Text { text, .. } if text == "please edit a.txt"
));
assert_eq!(app.api_messages[1].content.len(), 1);
assert!(matches!(
&app.api_messages[1].content[0],
ContentBlock::Text { text, .. } if text == "I will update the file."
));
}
#[test]
fn test_patch_undo_prunes_pre_turn_context() {
use crate::snapshot::SnapshotRepo;
use crate::test_support::lock_test_env;
use std::sync::MutexGuard;
use tempfile::tempdir;
struct HomeGuard {
prev: Option<std::ffi::OsString>,
_lock: MutexGuard<'static, ()>,
}
impl Drop for HomeGuard {
fn drop(&mut self) {
unsafe {
match self.prev.take() {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
}
}
}
fn scoped_home(home: &std::path::Path) -> HomeGuard {
let lock = lock_test_env();
let prev = std::env::var_os("HOME");
unsafe {
std::env::set_var("HOME", home);
}
HomeGuard { prev, _lock: lock }
}
let tmp = tempdir().unwrap();
let workspace = tmp.path().join("ws");
std::fs::create_dir_all(&workspace).unwrap();
let _guard = scoped_home(tmp.path());
let repo = SnapshotRepo::open_or_init(&workspace).unwrap();
let file = workspace.join("a.txt");
std::fs::write(&file, b"alpha").unwrap();
repo.snapshot("pre-turn:1").unwrap();
std::fs::write(&file, b"alpha-fixed").unwrap();
let mut app = create_test_app();
app.workspace = workspace.clone();
app.history.push(HistoryCell::User {
content: "please edit a.txt".to_string(),
});
app.history.push(HistoryCell::Assistant {
content: "Done, file is fixed now.".to_string(),
streaming: false,
});
app.api_messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "please edit a.txt".to_string(),
cache_control: None,
}],
});
app.api_messages.push(Message {
role: "assistant".to_string(),
content: vec![ContentBlock::Text {
text: "Done, file is fixed now.".to_string(),
cache_control: None,
}],
});
let result = patch_undo(&mut app);
assert!(!result.is_error);
assert_eq!(std::fs::read_to_string(&file).unwrap(), "alpha");
assert_eq!(app.history.len(), 1);
assert!(matches!(
app.history.last(),
Some(HistoryCell::System { content }) if content.contains("/undo reverted workspace")
));
assert!(app.api_messages.is_empty());
}
#[test]
fn test_prune_undone_tool_context_preserves_prior_tool_pairs() {
let mut app = create_test_app();
app.history.push(HistoryCell::User {
content: "edit two files".to_string(),
});
app.history.push(HistoryCell::Assistant {
content: "I will update both files.".to_string(),
streaming: false,
});
app.history
.push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: "write_file".to_string(),
status: ToolStatus::Success,
input_summary: Some("a.txt".to_string()),
output: Some("updated a".to_string()),
prompts: None,
spillover_path: None,
output_summary: None,
is_diff: false,
})));
app.history
.push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: "write_file".to_string(),
status: ToolStatus::Success,
input_summary: Some("b.txt".to_string()),
output: Some("updated b".to_string()),
prompts: None,
spillover_path: None,
output_summary: None,
is_diff: false,
})));
app.history.push(HistoryCell::Assistant {
content: "Done.".to_string(),
streaming: false,
});
app.tool_cells.insert("call-a".to_string(), 2);
app.tool_cells.insert("call-b".to_string(), 3);
app.api_messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "edit two files".to_string(),
cache_control: None,
}],
});
app.api_messages.push(Message {
role: "assistant".to_string(),
content: vec![
ContentBlock::Text {
text: "I will update both files.".to_string(),
cache_control: None,
},
ContentBlock::ToolUse {
id: "call-a".to_string(),
name: "write_file".to_string(),
input: serde_json::json!({"path": "a.txt"}),
caller: None,
},
ContentBlock::ToolUse {
id: "call-b".to_string(),
name: "write_file".to_string(),
input: serde_json::json!({"path": "b.txt"}),
caller: None,
},
],
});
app.api_messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: "call-a".to_string(),
content: "updated a".to_string(),
is_error: None,
content_blocks: None,
}],
});
app.api_messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: "call-b".to_string(),
content: "updated b".to_string(),
is_error: None,
content_blocks: None,
}],
});
app.api_messages.push(Message {
role: "assistant".to_string(),
content: vec![ContentBlock::Text {
text: "Done.".to_string(),
cache_control: None,
}],
});
prune_undone_tool_context(&mut app, "call-b");
assert_eq!(app.history.len(), 3);
assert_eq!(app.api_messages.len(), 3);
assert!(matches!(
&app.api_messages[1].content[..],
[
ContentBlock::Text { .. },
ContentBlock::ToolUse { id, .. }
] if id == "call-a"
));
assert!(matches!(
&app.api_messages[2].content[0],
ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == "call-a"
));
}
#[test]
fn cache_stats_no_data_before_first_turn() {
let mut app = create_test_app();
let result = cache(&mut app, Some("stats"));
let msg = result.message.expect("cache stats produces a message");
assert!(msg.contains("Cache Stats"), "got: {msg}");
assert!(
msg.contains("unknown (no checks recorded yet)"),
"got: {msg}"
);
assert!(msg.contains("Pinned hash: unavailable"), "got: {msg}");
assert!(msg.contains("No turn telemetry recorded yet"), "got: {msg}");
}
#[test]
fn cache_stats_shows_stable_prefix_with_hash() {
let mut app = create_test_app();
app.prefix_stability_pct = Some(100);
app.prefix_checks_total = 5;
app.prefix_change_count = 0;
app.last_pinned_prefix_hash =
Some("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2".to_string());
let result = cache(&mut app, Some("stats"));
let msg = result.message.expect("cache stats produces a message");
assert!(msg.contains("Stability: 100%"), "got: {msg}");
assert!(msg.contains("stable (no prefix changes"), "got: {msg}");
assert!(msg.contains("Pinned hash: a1b2c3d4e5f6"), "got: {msg}");
assert!(
msg.contains("Drift: none (hash stable)"),
"got: {msg}"
);
}
#[test]
fn cache_stats_warns_on_prefix_change() {
let mut app = create_test_app();
app.prefix_stability_pct = Some(67);
app.prefix_checks_total = 3;
app.prefix_change_count = 1;
app.last_prefix_change_desc =
Some("prefix cache invalidated: system prompt changed".to_string());
app.last_pinned_prefix_hash = Some(
"deadbeef0000deadbeef0000deadbeef0000deadbeef0000deadbeef0000deadbeef".to_string(),
);
let result = cache(&mut app, Some("stats"));
let msg = result.message.expect("cache stats produces a message");
assert!(msg.contains("Stability: 67%"), "got: {msg}");
assert!(msg.contains("WARNING — prefix has changed"), "got: {msg}");
assert!(msg.contains("system prompt changed"), "got: {msg}");
assert!(msg.contains("Drift: WARNING"), "got: {msg}");
assert!(msg.contains("1 change detected"), "got: {msg}");
}
#[test]
fn cache_stats_shows_cache_hit_summary() {
let mut app = create_test_app();
app.prefix_stability_pct = Some(100);
app.prefix_checks_total = 1;
app.last_pinned_prefix_hash =
Some("abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234".to_string());
app.push_turn_cache_record(TurnCacheRecord {
input_tokens: 10_000,
output_tokens: 1_000,
cache_hit_tokens: Some(8_000),
cache_miss_tokens: Some(2_000),
reasoning_replay_tokens: None,
recorded_at: Instant::now(),
});
app.push_turn_cache_record(TurnCacheRecord {
input_tokens: 5_000,
output_tokens: 500,
cache_hit_tokens: Some(4_500),
cache_miss_tokens: Some(500),
reasoning_replay_tokens: None,
recorded_at: Instant::now(),
});
let result = cache(&mut app, Some("stats"));
let msg = result.message.expect("cache stats produces a message");
assert!(msg.contains("Turns recorded: 2"), "got: {msg}");
assert!(msg.contains("83.3%"), "got: {msg}");
}
#[test]
fn cache_stats_low_hit_rate_shows_note() {
let mut app = create_test_app();
app.prefix_stability_pct = Some(100);
app.prefix_checks_total = 1;
app.last_pinned_prefix_hash =
Some("abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234".to_string());
app.push_turn_cache_record(TurnCacheRecord {
input_tokens: 10_000,
output_tokens: 1_000,
cache_hit_tokens: Some(1_000),
cache_miss_tokens: Some(9_000),
reasoning_replay_tokens: None,
recorded_at: Instant::now(),
});
let result = cache(&mut app, Some("stats"));
let msg = result.message.expect("cache stats produces a message");
assert!(msg.contains("10.0%"), "got: {msg}");
assert!(
msg.contains("cache hit rate is low"),
"should show low-hit-rate advisory, got: {msg}"
);
}
#[test]
fn format_tokens_handles_all_scales() {
assert_eq!(format_tokens(0), "0");
assert_eq!(format_tokens(999), "999");
assert_eq!(format_tokens(1_000), "1.0K");
assert_eq!(format_tokens(15_500), "15.5K");
assert_eq!(format_tokens(1_000_000), "1.0M");
assert_eq!(format_tokens(2_500_000), "2.5M");
}
}
pub fn undo_conversation(app: &mut App) -> CommandResult {
let mut removed_count = 0;
while !app.history.is_empty() {
let last_is_user = matches!(app.history.last(), Some(HistoryCell::User { .. }));
app.pop_history();
removed_count += 1;
if last_is_user {
break;
}
}
while let Some(last) = app.api_messages.last() {
if last.role == "user" {
app.api_messages.pop();
break;
}
app.api_messages.pop();
}
if removed_count > 0 {
app.tool_cells.clear();
app.tool_details_by_cell.clear();
app.exploring_entries.clear();
app.ignored_tool_calls.clear();
app.mark_history_updated();
CommandResult::message(format!("Removed {removed_count} message(s)"))
} else {
CommandResult::message("Nothing to undo")
}
}
fn prune_undone_tool_context(app: &mut App, tool_id: &str) {
if let Some(history_idx) = app.tool_cells.get(tool_id).copied() {
app.truncate_history_to(history_idx);
}
let Some((msg_idx, block_idx)) =
app.api_messages
.iter()
.enumerate()
.find_map(|(msg_idx, msg)| {
msg.content
.iter()
.position(
|block| matches!(block, ContentBlock::ToolUse { id, .. } if id == tool_id),
)
.map(|block_idx| (msg_idx, block_idx))
})
else {
return;
};
let kept_blocks = app.api_messages[msg_idx].content[..block_idx].to_vec();
let kept_tool_ids: std::collections::HashSet<String> = kept_blocks
.iter()
.filter_map(|block| match block {
ContentBlock::ToolUse { id, .. } => Some(id.clone()),
_ => None,
})
.collect();
if kept_blocks.is_empty() {
app.api_messages.truncate(msg_idx);
return;
}
let preserved_tool_results: Vec<_> =
app.api_messages
.iter()
.skip(msg_idx + 1)
.take_while(|msg| {
msg.role == "user"
&& !msg.content.is_empty()
&& msg
.content
.iter()
.all(|block| tool_result_id(block).is_some())
})
.filter(|msg| {
msg.role == "user"
&& !msg.content.is_empty()
&& msg.content.iter().all(|block| {
tool_result_id(block).is_some_and(|id| kept_tool_ids.contains(id))
})
})
.cloned()
.collect();
app.api_messages.truncate(msg_idx + 1);
app.api_messages[msg_idx].content = kept_blocks;
app.api_messages.extend(preserved_tool_results);
}
fn prune_undone_turn_context(app: &mut App) {
if let Some(history_idx) = app
.history
.iter()
.rposition(|cell| matches!(cell, HistoryCell::User { .. }))
{
app.truncate_history_to(history_idx);
}
if let Some(api_idx) = app.api_messages.iter().rposition(|msg| msg.role == "user") {
app.api_messages.truncate(api_idx);
}
}
fn tool_result_id(block: &ContentBlock) -> Option<&String> {
match block {
ContentBlock::ToolResult { tool_use_id, .. }
| ContentBlock::ToolSearchToolResult { tool_use_id, .. }
| ContentBlock::CodeExecutionToolResult { tool_use_id, .. } => Some(tool_use_id),
_ => None,
}
}
pub fn patch_undo(app: &mut App) -> CommandResult {
let workspace = app.workspace.clone();
let repo = match crate::snapshot::SnapshotRepo::open_or_init(&workspace) {
Ok(r) => r,
Err(e) => {
return CommandResult::error(format!(
"Snapshot repo unavailable for {}: {e}",
workspace.display(),
));
}
};
let snapshots = match repo.list(20) {
Ok(s) => s,
Err(e) => {
return CommandResult::error(format!("Failed to list snapshots: {e}"));
}
};
if snapshots.is_empty() {
return CommandResult::message("No snapshots found to undo — nothing to revert.");
}
let target = snapshots
.iter()
.filter(|s| s.label.starts_with("tool:") || s.label.starts_with("pre-turn:"))
.find(|s| match repo.work_tree_matches_snapshot(&s.id) {
Ok(matches) => !matches,
Err(_) => true,
});
let Some(target) = target else {
return CommandResult::message(
"No older tool or pre-turn snapshots differ from the current workspace — nothing to revert.",
);
};
if let Err(e) = repo.restore(&target.id) {
return CommandResult::error(format!("Restore failed: {e}"));
}
if let Some(tool_id) = target.label.strip_prefix("tool:") {
prune_undone_tool_context(app, tool_id);
} else if target.label.starts_with("pre-turn:") {
prune_undone_turn_context(app);
}
let diff_stat = Git::command()
.map(|mut git| {
git.args(["diff", "--stat"])
.current_dir(&workspace)
.output()
.ok()
.and_then(|o| {
let s = String::from_utf8_lossy(&o.stdout).trim().to_string();
if s.is_empty() { None } else { Some(s) }
})
})
.unwrap_or(None);
let short = &target.id.as_str()[..target.id.as_str().len().min(8)];
let summary = match diff_stat {
Some(ref stat) => {
format!(
"Restored snapshot '{}' ({}). Files affected:\n{stat}",
target.label, short
)
}
None => {
format!(
"Restored snapshot '{}' ({}). No diff changes detected.",
target.label, short
)
}
};
app.push_history_cell(HistoryCell::System {
content: format!(
"/undo reverted workspace to snapshot '{}' ({})",
target.label, short
),
});
CommandResult::with_message_and_action(
summary,
AppAction::SyncSession {
session_id: app.current_session_id.clone(),
messages: app.api_messages.clone(),
system_prompt: app.system_prompt.clone(),
model: app.model.clone(),
workspace: app.workspace.clone(),
},
)
}
pub fn edit(app: &mut App) -> CommandResult {
let last_user = app.history.iter().rev().find_map(|cell| match cell {
HistoryCell::User { content } => Some(content.clone()),
_ => None,
});
match last_user {
Some(content) => {
app.input = content;
app.cursor_position = app.input.chars().count();
app.edit_in_progress = true;
CommandResult::message(
"Last message loaded into composer — edit and press Enter to resubmit",
)
}
None => CommandResult::message("No previous message to edit"),
}
}
pub fn diff(app: &mut App) -> CommandResult {
let workspace = app.workspace.clone();
let Some(mut name_only_cmd) = Git::command() else {
return CommandResult::error("git not found on PATH");
};
let Some(mut stat_cmd) = Git::command() else {
return CommandResult::error("git not found on PATH");
};
let name_only_output = name_only_cmd
.args(["diff", "--name-only"])
.current_dir(&workspace)
.output();
let stat_output = stat_cmd
.args(["diff", "--stat"])
.current_dir(&workspace)
.output();
match (name_only_output, stat_output) {
(Ok(name_only), Ok(stat)) => {
let name_stdout = String::from_utf8_lossy(&name_only.stdout);
let stat_stdout = String::from_utf8_lossy(&stat.stdout);
if name_stdout.trim().is_empty() {
return CommandResult::message("No changes since session start");
}
let files: Vec<&str> = name_stdout.lines().filter(|l| !l.is_empty()).collect();
let file_count = files.len();
let file_list = files.join("\n");
let renamed_count = files.iter().filter(|f| f.contains(" -> ")).count();
let summary = if renamed_count > 0 {
format!("Changed files ({file_count}, {renamed_count} renamed):\n{file_list}")
} else {
format!("Changed files ({file_count}):\n{file_list}")
};
let stat_str = stat_stdout.trim();
let mut message = summary;
if !stat_str.is_empty() {
message.push_str("\n\n── Stat ──\n");
message.push_str(stat_str);
}
CommandResult::message(message)
}
(Err(e), _) | (_, Err(e)) => {
CommandResult::message(format!("Git diff failed — is this a git repository?\n{e}"))
}
}
}
pub fn retry(app: &mut App) -> CommandResult {
let last_user_input = app.history.iter().rev().find_map(|cell| match cell {
HistoryCell::User { content } => Some(content.clone()),
_ => None,
});
match last_user_input {
Some(input) => {
undo_conversation(app);
let display_input = if input.len() > 50 {
let truncate_at = input
.char_indices()
.take_while(|(i, _)| *i <= 50)
.last()
.map_or(0, |(i, _)| i);
format!("{}...", &input[..truncate_at])
} else {
input.clone()
};
CommandResult::with_message_and_action(
format!("Retrying: {display_input}"),
AppAction::SendMessage(input),
)
}
None => CommandResult::error("No previous request to retry"),
}
}