| 1 | use super::cache::{cache, format_tokens, format_warmup_status}; |
| 2 | use super::tokens::{context, cost, system_prompt, tokens}; |
| 3 | use super::undo::{patch_undo, prune_undone_tool_context, retry, undo_conversation}; |
| 4 | use crate::client::CacheWarmupKey; |
| 5 | use crate::config::Config; |
| 6 | use crate::tui::app::{App, AppAction, TuiOptions, TurnCacheRecord}; |
| 7 | use crate::tui::history::{GenericToolCell, HistoryCell, ToolCell, ToolStatus}; |
| 8 | use codewhale_models::Role; |
| 9 | use codewhale_models::{ContentBlock, Message, SystemBlock, SystemPrompt, Tool}; |
| 10 | use std::path::PathBuf; |
| 11 | use std::time::Instant; |
| 12 | |
| 13 | fn create_test_app() -> App { |
| 14 | let options = TuiOptions { |
| 15 | skills_dir: PathBuf::from("/tmp/test-skills"), |
| 16 | ..crate::test_support::test_tui_options(PathBuf::from("/tmp/test-workspace")) |
| 17 | }; |
| 18 | let mut app = App::new(options, &Config::default()); |
| 19 | app.ui_locale = codewhale_localization::Locale::En; |
| 20 | app.cost_currency = crate::pricing::CostCurrency::Usd; |
| 21 | app.api_provider = crate::config::ApiProvider::Deepseek; |
| 22 | app |
| 23 | } |
| 24 | |
| 25 | fn test_tool(name: &str) -> Tool { |
| 26 | Tool { |
| 27 | tool_type: Some("function".to_string()), |
| 28 | name: name.to_string(), |
| 29 | description: format!("{name} test tool"), |
| 30 | input_schema: serde_json::json!({ |
| 31 | "type": "object", |
| 32 | "properties": { |
| 33 | "path": {"type": "string"} |
| 34 | } |
| 35 | }), |
| 36 | allowed_callers: None, |
| 37 | defer_loading: Some(false), |
| 38 | input_examples: None, |
| 39 | strict: Some(true), |
| 40 | cache_control: None, |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | #[test] |
| 45 | fn test_tokens_shows_usage_info() { |
| 46 | let mut app = create_test_app(); |
| 47 | app.session.total_tokens = 1234; |
| 48 | app.session.session_cost = 0.05; |
| 49 | app.session.last_prompt_tokens = Some(100); |
| 50 | app.session.last_completion_tokens = Some(25); |
| 51 | app.session.last_prompt_cache_hit_tokens = Some(70); |
| 52 | app.session.last_prompt_cache_miss_tokens = Some(30); |
| 53 | app.api_messages_mut().push(Message { |
| 54 | role: Role::User, |
| 55 | content: vec![ContentBlock::Text { |
| 56 | text: "test".to_string(), |
| 57 | cache_control: None, |
| 58 | }], |
| 59 | }); |
| 60 | app.history.push(HistoryCell::User { |
| 61 | content: "test".to_string(), |
| 62 | }); |
| 63 | |
| 64 | let result = tokens(&mut app); |
| 65 | assert!(result.message.is_some()); |
| 66 | let msg = result.message.unwrap(); |
| 67 | assert!(msg.contains("Token Usage")); |
| 68 | assert!(msg.contains("Active context:")); |
| 69 | assert!(msg.contains("Last API input:")); |
| 70 | assert!(msg.contains("Last API output:")); |
| 71 | assert!(msg.contains("Cache hit/miss:")); |
| 72 | assert!(msg.contains("70 hit / 30 miss")); |
| 73 | assert!(msg.contains("Cumulative tokens:")); |
| 74 | // Not "approx session cost": the figure is the priced subtotal, and a |
| 75 | // session with no priced turn reports `unknown` rather than the raw |
| 76 | // accumulator (#4318). |
| 77 | assert!(msg.contains("Priced amount:")); |
| 78 | assert!(msg.contains("Priced amount: unknown"), "{msg}"); |
| 79 | assert!(msg.contains("API messages:")); |
| 80 | assert!(msg.contains("Chat messages:")); |
| 81 | assert!(msg.contains("Model:")); |
| 82 | } |
| 83 | |
| 84 | #[test] |
| 85 | fn tokens_report_uses_codex_oauth_route_context() { |
| 86 | let mut app = create_test_app(); |
| 87 | app.api_provider = crate::config::ApiProvider::OpenaiCodex; |
| 88 | app.set_model_selection("gpt-5.5".to_string()); |
| 89 | app.active_route_limits = Some(codewhale_config::route::RouteLimits { |
| 90 | context_tokens: Some(272_000), |
| 91 | input_tokens: None, |
| 92 | output_tokens: None, |
| 93 | }); |
| 94 | |
| 95 | let message = tokens(&mut app).message.expect("tokens report"); |
| 96 | |
| 97 | assert!(message.contains("/ 272000"), "{message}"); |
| 98 | assert!(!message.contains("1050000"), "{message}"); |
| 99 | } |
| 100 | |
| 101 | #[test] |
| 102 | fn test_cost_shows_spending_info() { |
| 103 | let mut app = create_test_app(); |
| 104 | // A total is only reportable with the coverage that qualifies it. Setting |
| 105 | // the accumulator alone is the legacy shape, checked separately below. |
| 106 | app.session.cost_priced_turns = 1; |
| 107 | app.session.session_cost = 0.1234; |
| 108 | let result = cost(&mut app); |
| 109 | assert!(result.message.is_some()); |
| 110 | let msg = result.message.unwrap(); |
| 111 | assert!(msg.contains("Session Cost"), "{msg}"); |
| 112 | assert!(msg.contains("Estimated total:"), "{msg}"); |
| 113 | assert!(msg.contains("$0.1234"), "{msg}"); |
| 114 | // The old copy hedged with "approximate" and then printed a static |
| 115 | // "Provider API Pricing" rate card that was not what the number was |
| 116 | // computed from. Both are gone: the report now names its own basis and |
| 117 | // its coverage instead of gesturing at a price list (#4318). |
| 118 | assert!(msg.contains("estimate, not a bill"), "{msg}"); |
| 119 | assert!(msg.contains("Covered: 1 of 1"), "{msg}"); |
| 120 | assert!(!msg.contains("Provider API Pricing"), "{msg}"); |
| 121 | assert!(!msg.contains("DeepSeek API Pricing"), "{msg}"); |
| 122 | } |
| 123 | |
| 124 | /// The same accumulator with no coverage behind it is not a total. It is the |
| 125 | /// exact state a pre-coverage session restores into, and reporting it as |
| 126 | /// "Estimated total: $0.1234" would claim the figure is complete. |
| 127 | #[test] |
| 128 | fn cost_report_will_not_promote_an_unqualified_accumulator_to_a_total() { |
| 129 | let mut app = create_test_app(); |
| 130 | app.session.session_cost = 0.1234; |
| 131 | |
| 132 | let msg = cost(&mut app).message.expect("cost report"); |
| 133 | assert!(msg.contains("Estimated total: unknown"), "{msg}"); |
| 134 | assert!(!msg.contains("$0.1234"), "{msg}"); |
| 135 | } |
| 136 | |
| 137 | #[test] |
| 138 | fn cost_report_states_its_coverage_and_names_what_it_excludes() { |
| 139 | use crate::pricing::audit_turn_cost_for_provider_at; |
| 140 | |
| 141 | let mut app = create_test_app(); |
| 142 | let write_heavy = codewhale_models::Usage { |
| 143 | input_tokens: 1_000_000, |
| 144 | output_tokens: 100_000, |
| 145 | prompt_cache_hit_tokens: Some(200_000), |
| 146 | prompt_cache_write_tokens: Some(100_000), |
| 147 | ..Default::default() |
| 148 | }; |
| 149 | let now = chrono::Utc::now(); |
| 150 | |
| 151 | // One priced turn, one turn whose route publishes no cache-write rate, and |
| 152 | // one subscription turn that is not money-metered at all. |
| 153 | let priced = audit_turn_cost_for_provider_at( |
| 154 | crate::config::ApiProvider::Anthropic, |
| 155 | "claude-haiku-4-5", |
| 156 | &write_heavy, |
| 157 | now, |
| 158 | ); |
| 159 | assert!(priced.is_priced(), "fixture must be priced"); |
| 160 | app.record_turn_cost_audit(&priced); |
| 161 | app.accrue_session_cost_estimate(priced.estimate.expect("priced")); |
| 162 | |
| 163 | let unpriced = audit_turn_cost_for_provider_at( |
| 164 | crate::config::ApiProvider::Moonshot, |
| 165 | "kimi-k2.7-code", |
| 166 | &write_heavy, |
| 167 | now, |
| 168 | ); |
| 169 | assert!(!unpriced.is_priced(), "fixture must fail closed"); |
| 170 | app.record_turn_cost_audit(&unpriced); |
| 171 | |
| 172 | let oauth = audit_turn_cost_for_provider_at( |
| 173 | crate::config::ApiProvider::OpenaiCodex, |
| 174 | "gpt-5.5", |
| 175 | &write_heavy, |
| 176 | now, |
| 177 | ); |
| 178 | app.record_turn_cost_audit(&oauth); |
| 179 | assert!( |
| 180 | !app.session |
| 181 | .cost_unpriced_reasons |
| 182 | .contains("not_money_metered") |
| 183 | ); |
| 184 | |
| 185 | let msg = cost(&mut app).message.expect("cost report"); |
| 186 | |
| 187 | // Non-metered routes are not counted as "incomplete dollars". |
| 188 | assert!(msg.contains("Covered: 1 of 2"), "{msg}"); |
| 189 | assert!(msg.contains("estimate, not a bill"), "{msg}"); |
| 190 | assert!(msg.contains("Excluded: 1"), "{msg}"); |
| 191 | assert!(msg.contains("Priced subtotal:"), "{msg}"); |
| 192 | assert!(msg.contains("token class rate unavailable"), "{msg}"); |
| 193 | assert!(msg.contains("cache_write"), "{msg}"); |
| 194 | |
| 195 | // A run with no unpriced turns says so without an exclusion note. |
| 196 | let mut clean = create_test_app(); |
| 197 | clean.record_turn_cost_audit(&priced); |
| 198 | let clean_msg = cost(&mut clean).message.expect("cost report"); |
| 199 | assert!(clean_msg.contains("Covered: 1 of 1"), "{clean_msg}"); |
| 200 | assert!(clean_msg.contains("Estimated total:"), "{clean_msg}"); |
| 201 | assert!(!clean_msg.contains("Excluded:"), "{clean_msg}"); |
| 202 | assert!(clean_msg.contains("estimate, not a bill"), "{clean_msg}"); |
| 203 | // Provenance of the row the total was built from is part of explaining it. |
| 204 | assert!(clean_msg.contains("Pricing sources used:"), "{clean_msg}"); |
| 205 | } |
| 206 | |
| 207 | #[test] |
| 208 | fn cost_coverage_is_currency_specific_for_mixed_deepseek_openai() { |
| 209 | let mut app = create_test_app(); |
| 210 | let usage = codewhale_models::Usage { |
| 211 | input_tokens: 10_000, |
| 212 | output_tokens: 1_000, |
| 213 | ..Default::default() |
| 214 | }; |
| 215 | let deepseek = crate::pricing::audit_turn_cost_for_provider_at( |
| 216 | crate::config::ApiProvider::Deepseek, |
| 217 | "deepseek-v4-flash", |
| 218 | &usage, |
| 219 | chrono::Utc::now(), |
| 220 | ); |
| 221 | let openai = crate::pricing::audit_turn_cost_for_provider_at( |
| 222 | crate::config::ApiProvider::Openai, |
| 223 | "gpt-5.5", |
| 224 | &usage, |
| 225 | chrono::Utc::now(), |
| 226 | ); |
| 227 | app.record_turn_cost_audit(&deepseek); |
| 228 | app.record_turn_cost_audit(&openai); |
| 229 | app.accrue_session_cost_estimate(deepseek.estimate.expect("DeepSeek priced")); |
| 230 | app.accrue_session_cost_estimate(openai.estimate.expect("OpenAI priced")); |
| 231 | |
| 232 | app.cost_currency = crate::pricing::CostCurrency::Usd; |
| 233 | let usd = cost(&mut app).message.expect("USD report"); |
| 234 | assert!(usd.contains("Covered: 2 of 2"), "{usd}"); |
| 235 | assert!(usd.contains("Estimated total:"), "{usd}"); |
| 236 | |
| 237 | app.cost_currency = crate::pricing::CostCurrency::Cny; |
| 238 | let cny = cost(&mut app).message.expect("CNY report"); |
| 239 | assert!(cny.contains("Covered: 1 of 2"), "{cny}"); |
| 240 | assert!(cny.contains("Priced subtotal:"), "{cny}"); |
| 241 | } |
| 242 | |
| 243 | /// A session restored from a pre-coverage save has real money and no evidence of |
| 244 | /// what it covers. `/cost` must say the coverage is unknown rather than render a |
| 245 | /// fabricated "0 of 0 priced", which would assert the total is complete (#4318). |
| 246 | #[test] |
| 247 | fn cost_report_shows_unknown_coverage_for_a_legacy_session_instead_of_zero_of_zero() { |
| 248 | let mut app = create_test_app(); |
| 249 | app.session.session_cost = 1.25; |
| 250 | app.session.cost_coverage_unknown_legacy = true; |
| 251 | |
| 252 | let msg = cost(&mut app).message.expect("cost report"); |
| 253 | assert!(msg.contains("Coverage: unknown"), "{msg}"); |
| 254 | assert!( |
| 255 | !msg.contains("Covered: 0 of 0"), |
| 256 | "a legacy session must never claim a complete zero-turn total: {msg}" |
| 257 | ); |
| 258 | assert!(msg.contains("estimate, not a bill"), "{msg}"); |
| 259 | } |
| 260 | |
| 261 | #[test] |
| 262 | fn cost_report_distinguishes_unknown_from_authoritatively_priced_zero() { |
| 263 | let mut unknown = create_test_app(); |
| 264 | let unknown_msg = cost(&mut unknown).message.expect("unknown report"); |
| 265 | assert!( |
| 266 | unknown_msg.contains("Estimated total: unknown"), |
| 267 | "{unknown_msg}" |
| 268 | ); |
| 269 | assert!(!unknown_msg.contains("$0.0000"), "{unknown_msg}"); |
| 270 | |
| 271 | let mut priced_zero = create_test_app(); |
| 272 | priced_zero.session.cost_priced_turns = 1; |
| 273 | let zero_msg = cost(&mut priced_zero).message.expect("priced zero report"); |
| 274 | assert!(zero_msg.contains("$0.0000"), "{zero_msg}"); |
| 275 | assert!(!zero_msg.contains("<$0.0001"), "{zero_msg}"); |
| 276 | } |
| 277 | |
| 278 | #[test] |
| 279 | fn cost_report_distinguishes_bundled_fallback_from_no_usable_fallback() { |
| 280 | let mut app = create_test_app(); |
| 281 | app.session.cost_priced_turns = 1; |
| 282 | app.session |
| 283 | .cost_live_pricing_defects |
| 284 | .insert("live_pricing_stale".to_string()); |
| 285 | app.session |
| 286 | .cost_live_pricing_unusable_defects |
| 287 | .insert("live_pricing_scope_mismatch".to_string()); |
| 288 | |
| 289 | let msg = cost(&mut app).message.expect("cost report"); |
| 290 | assert!(msg.contains("bundled published rates were used"), "{msg}"); |
| 291 | assert!( |
| 292 | msg.contains("no usable bundled rate was available"), |
| 293 | "{msg}" |
| 294 | ); |
| 295 | assert!(msg.contains("this spend is unknown"), "{msg}"); |
| 296 | } |
| 297 | |
| 298 | #[test] |
| 299 | fn all_zero_legacy_coverage_stays_unknown_when_rendered_and_resaved() { |
| 300 | let mut app = create_test_app(); |
| 301 | let legacy: crate::session_manager::SessionCostSnapshot = |
| 302 | serde_json::from_value(serde_json::json!({ |
| 303 | "session_cost_usd": 0.0, |
| 304 | "session_cost_cny": 0.0 |
| 305 | })) |
| 306 | .expect("legacy zero snapshot"); |
| 307 | assert!(legacy.coverage_is_legacy_unknown()); |
| 308 | app.session.cost_coverage_unknown_legacy = true; |
| 309 | |
| 310 | let msg = cost(&mut app).message.expect("cost report"); |
| 311 | assert!(msg.contains("Coverage: unknown"), "{msg}"); |
| 312 | assert!(!msg.contains("Covered: 0 of 0"), "{msg}"); |
| 313 | |
| 314 | let mut metadata = crate::session_manager::create_saved_session_with_id_and_mode( |
| 315 | "legacy-zero".to_string(), |
| 316 | &[], |
| 317 | "deepseek-v4-flash", |
| 318 | std::path::Path::new("/tmp"), |
| 319 | 0, |
| 320 | None, |
| 321 | None, |
| 322 | ) |
| 323 | .metadata; |
| 324 | app.sync_cost_to_metadata(&mut metadata); |
| 325 | assert!(!metadata.cost.coverage_recorded); |
| 326 | assert!(metadata.cost.coverage_is_legacy_unknown()); |
| 327 | } |
| 328 | |
| 329 | /// Loading a session must not leave the previous session's coverage counters |
| 330 | /// attached to a total that no longer contains those turns. |
| 331 | #[test] |
| 332 | fn reset_cost_coverage_clears_every_counter() { |
| 333 | use crate::pricing::audit_turn_cost_for_provider_at; |
| 334 | |
| 335 | let mut app = create_test_app(); |
| 336 | let usage = codewhale_models::Usage { |
| 337 | input_tokens: 1_000_000, |
| 338 | output_tokens: 100_000, |
| 339 | prompt_cache_write_tokens: Some(100_000), |
| 340 | ..Default::default() |
| 341 | }; |
| 342 | let now = chrono::Utc::now(); |
| 343 | app.record_turn_cost_audit(&audit_turn_cost_for_provider_at( |
| 344 | crate::config::ApiProvider::Anthropic, |
| 345 | "claude-haiku-4-5", |
| 346 | &usage, |
| 347 | now, |
| 348 | )); |
| 349 | app.record_turn_cost_audit(&audit_turn_cost_for_provider_at( |
| 350 | crate::config::ApiProvider::Moonshot, |
| 351 | "kimi-k2.7-code", |
| 352 | &usage, |
| 353 | now, |
| 354 | )); |
| 355 | app.record_turn_cost_route_receipt("provider=anthropic model=x".to_string()); |
| 356 | app.session.cost_coverage_unknown_legacy = true; |
| 357 | assert_ne!(app.session.cost_priced_turns, 0); |
| 358 | assert_ne!(app.session.cost_unpriced_turns, 0); |
| 359 | assert!(!app.session.cost_unpriced_reasons.is_empty()); |
| 360 | assert!(!app.session.cost_unpriced_classes.is_empty()); |
| 361 | assert!(!app.session.cost_pricing_provenances.is_empty()); |
| 362 | assert!(!app.session.cost_route_receipts.is_empty()); |
| 363 | |
| 364 | app.reset_cost_coverage(); |
| 365 | |
| 366 | assert_eq!(app.session.cost_priced_turns, 0); |
| 367 | assert_eq!(app.session.cost_unpriced_turns, 0); |
| 368 | assert!(app.session.cost_unpriced_reasons.is_empty()); |
| 369 | assert!(app.session.cost_cny_unpriced_reasons.is_empty()); |
| 370 | assert!(app.session.cost_unpriced_classes.is_empty()); |
| 371 | assert!(app.session.cost_pricing_provenances.is_empty()); |
| 372 | assert!(app.session.cost_live_pricing_defects.is_empty()); |
| 373 | assert!(app.session.cost_live_pricing_unusable_defects.is_empty()); |
| 374 | assert!(app.session.cost_route_receipts.is_empty()); |
| 375 | assert!(!app.session.cost_coverage_unknown_legacy); |
| 376 | // With nothing recorded, `/cost` reports an honest empty coverage rather |
| 377 | // than the legacy-unknown state. |
| 378 | let msg = cost(&mut app).message.expect("cost report"); |
| 379 | assert!(msg.contains("Covered: 0 of 0"), "{msg}"); |
| 380 | assert!(!msg.contains("Coverage: unknown"), "{msg}"); |
| 381 | } |
| 382 | |
| 383 | /// `/tokens` quotes the same total as `/cost`, so it carries the same estimate |
| 384 | /// disclaimer and the same coverage state, and reports cache-write with a |
| 385 | /// pointer to `/cache` for the per-turn detail. |
| 386 | #[test] |
| 387 | fn tokens_report_says_estimate_and_exposes_coverage_and_cache_write() { |
| 388 | let mut app = create_test_app(); |
| 389 | app.session.total_cache_write_tokens = 250_000; |
| 390 | app.record_turn_cost_audit(&crate::pricing::audit_turn_cost_for_provider_at( |
| 391 | crate::config::ApiProvider::Moonshot, |
| 392 | "kimi-k2.7-code", |
| 393 | &codewhale_models::Usage { |
| 394 | input_tokens: 1_000_000, |
| 395 | output_tokens: 100_000, |
| 396 | prompt_cache_write_tokens: Some(100_000), |
| 397 | ..Default::default() |
| 398 | }, |
| 399 | chrono::Utc::now(), |
| 400 | )); |
| 401 | |
| 402 | let msg = tokens(&mut app).message.expect("tokens report"); |
| 403 | assert!(msg.contains("estimate, not a bill"), "{msg}"); |
| 404 | assert!(msg.contains("Covered: 0 of 1"), "{msg}"); |
| 405 | assert!(msg.contains("Excluded: 1"), "{msg}"); |
| 406 | assert!(msg.contains("250000"), "{msg}"); |
| 407 | // The cache-write line links to /cache rather than duplicating the table. |
| 408 | assert!(msg.contains("/cache"), "{msg}"); |
| 409 | } |
| 410 | |
| 411 | #[test] |
| 412 | fn test_system_prompt_displays_text() { |
| 413 | let mut app = create_test_app(); |
| 414 | app.system_prompt = Some(SystemPrompt::Text("Test system prompt".to_string())); |
| 415 | let result = system_prompt(&mut app); |
| 416 | assert!(result.message.is_some()); |
| 417 | let msg = result.message.unwrap(); |
| 418 | assert!(msg.contains("System Prompt")); |
| 419 | assert!(msg.contains("Test system prompt")); |
| 420 | } |
| 421 | |
| 422 | #[test] |
| 423 | fn test_system_prompt_displays_blocks() { |
| 424 | let mut app = create_test_app(); |
| 425 | app.system_prompt = Some(SystemPrompt::Blocks(vec![ |
| 426 | SystemBlock { |
| 427 | block_type: "text".to_string(), |
| 428 | text: "Block 1".to_string(), |
| 429 | cache_control: None, |
| 430 | }, |
| 431 | SystemBlock { |
| 432 | block_type: "text".to_string(), |
| 433 | text: "Block 2".to_string(), |
| 434 | cache_control: None, |
| 435 | }, |
| 436 | ])); |
| 437 | let result = system_prompt(&mut app); |
| 438 | assert!(result.message.is_some()); |
| 439 | let msg = result.message.unwrap(); |
| 440 | assert!(msg.contains("System Prompt")); |
| 441 | assert!(msg.contains("Block 1")); |
| 442 | assert!(msg.contains("Block 2")); |
| 443 | } |
| 444 | |
| 445 | #[test] |
| 446 | fn test_system_prompt_none() { |
| 447 | let mut app = create_test_app(); |
| 448 | app.system_prompt = None; |
| 449 | let result = system_prompt(&mut app); |
| 450 | assert!(result.message.is_some()); |
| 451 | let msg = result.message.unwrap(); |
| 452 | assert!(msg.contains("(no system prompt)")); |
| 453 | } |
| 454 | |
| 455 | #[test] |
| 456 | fn test_system_prompt_truncates_long_text() { |
| 457 | let mut app = create_test_app(); |
| 458 | let long_text = "x".repeat(600); |
| 459 | app.system_prompt = Some(SystemPrompt::Text(long_text)); |
| 460 | let result = system_prompt(&mut app); |
| 461 | assert!(result.message.is_some()); |
| 462 | let msg = result.message.unwrap(); |
| 463 | assert!(msg.contains("...")); |
| 464 | assert!(msg.contains("chars total")); |
| 465 | } |
| 466 | |
| 467 | #[test] |
| 468 | fn cache_command_reports_no_data_before_first_turn() { |
| 469 | let mut app = create_test_app(); |
| 470 | let result = cache(&mut app, None); |
| 471 | let msg = result.message.expect("cache produces a message"); |
| 472 | assert!(msg.contains("no turns recorded yet"), "got: {msg}"); |
| 473 | } |
| 474 | |
| 475 | #[test] |
| 476 | fn cache_inspect_reports_hashes_without_prompt_text() { |
| 477 | let mut app = create_test_app(); |
| 478 | app.system_prompt = Some(SystemPrompt::Text( |
| 479 | "Base policy\n\n<project_instructions source=\"AGENTS.md\">\nSECRET_PROJECT_RULE\n</project_instructions>" |
| 480 | .to_string(), |
| 481 | )); |
| 482 | app.api_messages_mut().push(Message { |
| 483 | role: Role::User, |
| 484 | content: vec![ContentBlock::Text { |
| 485 | text: "SECRET_USER_TASK".to_string(), |
| 486 | cache_control: None, |
| 487 | }], |
| 488 | }); |
| 489 | |
| 490 | let result = cache(&mut app, Some("inspect")); |
| 491 | let msg = result.message.expect("inspect output"); |
| 492 | |
| 493 | assert!(msg.contains("Cache Inspect")); |
| 494 | assert!(msg.contains("Base static prefix hash:")); |
| 495 | assert!(msg.contains("Full request prefix hash:")); |
| 496 | assert!(msg.contains("Static base prefix stability: no previous request")); |
| 497 | assert!(msg.contains("First divergence from previous request: unavailable")); |
| 498 | assert!(msg.contains("Global system prefix: static")); |
| 499 | assert!(msg.contains("Project context: static")); |
| 500 | assert!(msg.contains("User task: dynamic")); |
| 501 | assert!(!msg.contains("SECRET_PROJECT_RULE")); |
| 502 | assert!(!msg.contains("SECRET_USER_TASK")); |
| 503 | } |
| 504 | |
| 505 | #[test] |
| 506 | fn cache_inspect_uses_last_request_tool_catalog() { |
| 507 | let mut app = create_test_app(); |
| 508 | app.system_prompt = Some(SystemPrompt::Text("Base policy".to_string())); |
| 509 | app.session.last_tool_catalog = Some(vec![test_tool("read_file")]); |
| 510 | app.api_messages_mut().push(Message { |
| 511 | role: Role::User, |
| 512 | content: vec![ContentBlock::Text { |
| 513 | text: "Current task".to_string(), |
| 514 | cache_control: None, |
| 515 | }], |
| 516 | }); |
| 517 | |
| 518 | let msg = cache(&mut app, Some("inspect")) |
| 519 | .message |
| 520 | .expect("inspect output"); |
| 521 | |
| 522 | assert!(msg.contains("Tool catalog hash: "), "got: {msg}"); |
| 523 | assert!(!msg.contains("(no tools registered)"), "got: {msg}"); |
| 524 | assert!(msg.contains("Tool catalog: static"), "got: {msg}"); |
| 525 | assert!(msg.contains("bytes="), "got: {msg}"); |
| 526 | assert!(msg.contains("~"), "got: {msg}"); |
| 527 | } |
| 528 | |
| 529 | #[test] |
| 530 | fn cache_inspect_json_reports_tool_catalog_hash_and_layer_sizes() { |
| 531 | let mut app = create_test_app(); |
| 532 | app.system_prompt = Some(SystemPrompt::Text("Base policy".to_string())); |
| 533 | app.session.last_tool_catalog = Some(vec![test_tool("read_file")]); |
| 534 | app.api_messages_mut().push(Message { |
| 535 | role: Role::User, |
| 536 | content: vec![ContentBlock::Text { |
| 537 | text: "Current task".to_string(), |
| 538 | cache_control: None, |
| 539 | }], |
| 540 | }); |
| 541 | |
| 542 | let msg = cache(&mut app, Some("inspect --json")) |
| 543 | .message |
| 544 | .expect("inspect json output"); |
| 545 | let parsed: serde_json::Value = serde_json::from_str(&msg).expect("valid json"); |
| 546 | |
| 547 | assert_eq!(parsed["tool_catalog_hash"].as_str().unwrap().len(), 64); |
| 548 | assert!( |
| 549 | parsed["warmup_status"] |
| 550 | .as_str() |
| 551 | .is_some_and(|status| status.starts_with("Warmup status: no previous warmup")) |
| 552 | ); |
| 553 | assert!(parsed["current_warmup_key"].is_object()); |
| 554 | let tool_layer = parsed["layers"] |
| 555 | .as_array() |
| 556 | .unwrap() |
| 557 | .iter() |
| 558 | .find(|layer| layer["name"] == "Tool catalog") |
| 559 | .expect("tool catalog layer"); |
| 560 | assert!(tool_layer["byte_len"].as_u64().unwrap() > 0); |
| 561 | assert!(tool_layer["token_estimate"].as_u64().unwrap() > 0); |
| 562 | } |
| 563 | |
| 564 | #[test] |
| 565 | fn cache_inspect_json_keys_auto_replay_to_the_last_concrete_route() { |
| 566 | let mut app = create_test_app(); |
| 567 | app.model = "auto".to_string(); |
| 568 | app.auto_model = true; |
| 569 | app.reasoning_effort = crate::reasoning_preference::ReasoningEffort::Off; |
| 570 | app.last_effective_provider = Some(crate::config::ApiProvider::OpenaiCodex); |
| 571 | app.last_effective_provider_identity = |
| 572 | Some(crate::config::ApiProvider::OpenaiCodex.as_str().to_string()); |
| 573 | app.last_effective_model = Some(crate::config::DEFAULT_OPENAI_CODEX_MODEL.to_string()); |
| 574 | app.session.last_base_url = Some(crate::config::DEFAULT_OPENAI_CODEX_BASE_URL.to_string()); |
| 575 | app.push_turn_cache_record(TurnCacheRecord { |
| 576 | provider: Some(crate::config::ApiProvider::OpenaiCodex), |
| 577 | provider_identity: Some(crate::config::ApiProvider::OpenaiCodex.as_str().to_string()), |
| 578 | model: Some(crate::config::DEFAULT_OPENAI_CODEX_MODEL.to_string()), |
| 579 | auto_model: true, |
| 580 | input_tokens: 1, |
| 581 | output_tokens: 1, |
| 582 | cache_hit_tokens: None, |
| 583 | cache_miss_tokens: None, |
| 584 | cache_write_tokens: None, |
| 585 | reasoning_tokens: None, |
| 586 | cost_audit: None, |
| 587 | reasoning_replay_tokens: None, |
| 588 | recorded_at: Instant::now(), |
| 589 | }); |
| 590 | |
| 591 | let message = cache(&mut app, Some("inspect --json")) |
| 592 | .message |
| 593 | .expect("inspect json output"); |
| 594 | let parsed: serde_json::Value = serde_json::from_str(&message).expect("valid json"); |
| 595 | let key = &parsed["current_warmup_key"]; |
| 596 | |
| 597 | assert_eq!( |
| 598 | key["provider"], |
| 599 | crate::config::ApiProvider::OpenaiCodex.as_str() |
| 600 | ); |
| 601 | assert_eq!(key["model"], crate::config::DEFAULT_OPENAI_CODEX_MODEL); |
| 602 | assert_eq!( |
| 603 | key["base_url"], |
| 604 | crate::config::DEFAULT_OPENAI_CODEX_BASE_URL |
| 605 | ); |
| 606 | } |
| 607 | |
| 608 | fn warmup_key(model: &str, static_hash: &str) -> CacheWarmupKey { |
| 609 | CacheWarmupKey { |
| 610 | provider: "Deepseek".to_string(), |
| 611 | model: model.to_string(), |
| 612 | base_url: "https://api.deepseek.com".to_string(), |
| 613 | static_prefix_hash: static_hash.to_string(), |
| 614 | tool_catalog_hash: "tool".to_string(), |
| 615 | project_pack_hash: "project".to_string(), |
| 616 | skills_hash: "skills".to_string(), |
| 617 | } |
| 618 | } |
| 619 | |
| 620 | #[test] |
| 621 | fn warmup_status_reports_valid_matching_key() { |
| 622 | let key = warmup_key("deepseek-v4-pro", "static-a"); |
| 623 | let result = format_warmup_status(Some(&key), &key); |
| 624 | assert!(result.contains("Warmup status: valid"), "got: {result}"); |
| 625 | } |
| 626 | |
| 627 | #[test] |
| 628 | fn warmup_status_reports_invalidation_reason() { |
| 629 | let previous = warmup_key("deepseek-v4-pro", "static-a"); |
| 630 | let current = warmup_key("deepseek-v4-flash", "static-b"); |
| 631 | let result = format_warmup_status(Some(&previous), ¤t); |
| 632 | assert!(result.contains("Warmup status: invalid"), "got: {result}"); |
| 633 | assert!(result.contains("model changed"), "got: {result}"); |
| 634 | assert!(result.contains("static prefix changed"), "got: {result}"); |
| 635 | } |
| 636 | |
| 637 | #[test] |
| 638 | fn warmup_status_reports_project_and_skills_reasons() { |
| 639 | let previous = warmup_key("deepseek-v4-pro", "static-a"); |
| 640 | let mut current = previous.clone(); |
| 641 | current.project_pack_hash = "project-b".to_string(); |
| 642 | current.skills_hash = "skills-b".to_string(); |
| 643 | |
| 644 | let result = format_warmup_status(Some(&previous), ¤t); |
| 645 | |
| 646 | assert!(result.contains("project pack changed"), "got: {result}"); |
| 647 | assert!(result.contains("skills changed"), "got: {result}"); |
| 648 | assert!(!result.contains("; )"), "got: {result}"); |
| 649 | } |
| 650 | |
| 651 | #[test] |
| 652 | fn cache_inspect_rejects_json_verbose_combo() { |
| 653 | let mut app = create_test_app(); |
| 654 | let msg = cache(&mut app, Some("inspect --json --verbose")) |
| 655 | .message |
| 656 | .expect("inspect output"); |
| 657 | |
| 658 | assert_eq!( |
| 659 | msg, |
| 660 | "cache inspect: --json and --verbose cannot be combined" |
| 661 | ); |
| 662 | } |
| 663 | |
| 664 | #[test] |
| 665 | fn cache_inspect_json_uses_cjk_aware_token_estimate() { |
| 666 | let mut app = create_test_app(); |
| 667 | app.system_prompt = Some(SystemPrompt::Text("缓存命中测试".to_string())); |
| 668 | |
| 669 | let msg = cache(&mut app, Some("inspect --json")) |
| 670 | .message |
| 671 | .expect("inspect json output"); |
| 672 | let parsed: serde_json::Value = serde_json::from_str(&msg).expect("valid json"); |
| 673 | let system_layer = parsed["layers"] |
| 674 | .as_array() |
| 675 | .unwrap() |
| 676 | .iter() |
| 677 | .find(|layer| layer["name"] == "Global system prefix") |
| 678 | .expect("system layer"); |
| 679 | |
| 680 | assert_eq!( |
| 681 | system_layer["token_estimate"].as_u64(), |
| 682 | system_layer["char_len"].as_u64() |
| 683 | ); |
| 684 | } |
| 685 | |
| 686 | #[test] |
| 687 | fn cache_inspect_reports_divergence_from_previous_request() { |
| 688 | let mut app = create_test_app(); |
| 689 | app.system_prompt = Some(SystemPrompt::Text( |
| 690 | "Base policy\n\n## Environment\n\n- shell: powershell".to_string(), |
| 691 | )); |
| 692 | app.api_messages_mut().push(Message { |
| 693 | role: Role::Assistant, |
| 694 | content: vec![codewhale_models::ContentBlock::Text { |
| 695 | text: "Prior answer".to_string(), |
| 696 | cache_control: None, |
| 697 | }], |
| 698 | }); |
| 699 | app.api_messages_mut().push(Message { |
| 700 | role: Role::User, |
| 701 | content: vec![codewhale_models::ContentBlock::Text { |
| 702 | text: "First task".to_string(), |
| 703 | cache_control: None, |
| 704 | }], |
| 705 | }); |
| 706 | |
| 707 | let first = cache(&mut app, Some("inspect")) |
| 708 | .message |
| 709 | .expect("first inspect output"); |
| 710 | assert!(first.contains("Static base prefix stability: no previous request")); |
| 711 | |
| 712 | if let Some(last) = app.api_messages_mut().last_mut() |
| 713 | && let Some(codewhale_models::ContentBlock::Text { text, .. }) = last.content.first_mut() |
| 714 | { |
| 715 | *text = "Second task".to_string(); |
| 716 | } |
| 717 | |
| 718 | let second = cache(&mut app, Some("inspect")) |
| 719 | .message |
| 720 | .expect("second inspect output"); |
| 721 | assert!(second.contains("Static base prefix stability: OK")); |
| 722 | assert!(second.contains("First divergence from previous request: User task")); |
| 723 | assert!(second.contains("Message #1 assistant: history")); |
| 724 | } |
| 725 | |
| 726 | #[test] |
| 727 | fn cache_inspect_displays_tool_result_budget_metadata() { |
| 728 | let mut app = create_test_app(); |
| 729 | let long_output = format!("{}{}", "A".repeat(7_000), "Z".repeat(7_000)); |
| 730 | app.api_messages_mut().push(Message { |
| 731 | role: Role::Assistant, |
| 732 | content: vec![ContentBlock::ToolUse { |
| 733 | id: "tool-1".to_string(), |
| 734 | name: "shell_command".to_string(), |
| 735 | input: serde_json::json!({"command": "cargo test"}), |
| 736 | caller: None, |
| 737 | thought_signature: None, |
| 738 | }], |
| 739 | }); |
| 740 | app.api_messages_mut().push(Message { |
| 741 | role: Role::User, |
| 742 | content: vec![ContentBlock::ToolResult { |
| 743 | tool_use_id: "tool-1".to_string(), |
| 744 | content: long_output.clone(), |
| 745 | is_error: None, |
| 746 | content_blocks: None, |
| 747 | }], |
| 748 | }); |
| 749 | app.api_messages_mut().push(Message { |
| 750 | role: Role::Assistant, |
| 751 | content: vec![ContentBlock::ToolUse { |
| 752 | id: "tool-2".to_string(), |
| 753 | name: "shell_command".to_string(), |
| 754 | input: serde_json::json!({"command": "cargo test"}), |
| 755 | caller: None, |
| 756 | thought_signature: None, |
| 757 | }], |
| 758 | }); |
| 759 | app.api_messages_mut().push(Message { |
| 760 | role: Role::User, |
| 761 | content: vec![ContentBlock::ToolResult { |
| 762 | tool_use_id: "tool-2".to_string(), |
| 763 | content: long_output, |
| 764 | is_error: None, |
| 765 | content_blocks: None, |
| 766 | }], |
| 767 | }); |
| 768 | |
| 769 | let result = cache(&mut app, Some("inspect")); |
| 770 | let msg = result.message.expect("inspect output"); |
| 771 | |
| 772 | let tool_budget_lines: Vec<_> = msg |
| 773 | .lines() |
| 774 | .filter(|line| line.contains("original_chars=14000")) |
| 775 | .collect(); |
| 776 | assert_eq!(tool_budget_lines.len(), 2, "got: {msg}"); |
| 777 | |
| 778 | for sighting in tool_budget_lines { |
| 779 | assert!(sighting.contains("sent_chars="), "got: {msg}"); |
| 780 | assert!(sighting.contains("truncated=true"), "got: {msg}"); |
| 781 | assert!(sighting.contains("deduplicated=false"), "got: {msg}"); |
| 782 | } |
| 783 | } |
| 784 | |
| 785 | #[test] |
| 786 | fn cache_inspect_displays_turn_meta_dedup_metadata() { |
| 787 | let mut app = create_test_app(); |
| 788 | let turn_meta = format!( |
| 789 | "<turn_meta>\nCurrent local date: 2026-05-09\n{}\n</turn_meta>", |
| 790 | "Working set: src/lib.rs\n".repeat(20) |
| 791 | ); |
| 792 | app.api_messages_mut().push(Message { |
| 793 | role: Role::User, |
| 794 | content: vec![ |
| 795 | ContentBlock::Text { |
| 796 | text: turn_meta.clone(), |
| 797 | cache_control: None, |
| 798 | }, |
| 799 | ContentBlock::Text { |
| 800 | text: "first task".to_string(), |
| 801 | cache_control: None, |
| 802 | }, |
| 803 | ], |
| 804 | }); |
| 805 | app.api_messages_mut().push(Message { |
| 806 | role: Role::User, |
| 807 | content: vec![ |
| 808 | ContentBlock::Text { |
| 809 | text: turn_meta, |
| 810 | cache_control: None, |
| 811 | }, |
| 812 | ContentBlock::Text { |
| 813 | text: "second task".to_string(), |
| 814 | cache_control: None, |
| 815 | }, |
| 816 | ], |
| 817 | }); |
| 818 | |
| 819 | let result = cache(&mut app, Some("inspect")); |
| 820 | let msg = result.message.expect("inspect output"); |
| 821 | |
| 822 | assert!(msg.contains("turn_meta_original_chars="), "got: {msg}"); |
| 823 | assert!(msg.contains("turn_meta_sent_chars="), "got: {msg}"); |
| 824 | assert!(msg.contains("turn_meta_deduplicated=false"), "got: {msg}"); |
| 825 | assert!(msg.contains("turn_meta_deduplicated=true"), "got: {msg}"); |
| 826 | assert!(msg.contains("turn_meta_sha256="), "got: {msg}"); |
| 827 | assert!(!msg.contains("Working set: src/lib.rs"), "got: {msg}"); |
| 828 | } |
| 829 | |
| 830 | #[test] |
| 831 | fn cache_command_renders_recorded_turns_with_ratio() { |
| 832 | let mut app = create_test_app(); |
| 833 | let now = Instant::now(); |
| 834 | // Three turns: 75% hit, 50% hit, miss-only (provider didn't report hit). |
| 835 | app.push_turn_cache_record(TurnCacheRecord { |
| 836 | provider: Some(crate::config::ApiProvider::Deepseek), |
| 837 | provider_identity: Some("deepseek".to_string()), |
| 838 | model: Some("deepseek-v4-pro".to_string()), |
| 839 | auto_model: true, |
| 840 | input_tokens: 4_000, |
| 841 | output_tokens: 200, |
| 842 | cache_hit_tokens: Some(3_000), |
| 843 | cache_miss_tokens: Some(1_000), |
| 844 | reasoning_replay_tokens: None, |
| 845 | cache_write_tokens: None, |
| 846 | reasoning_tokens: None, |
| 847 | cost_audit: None, |
| 848 | recorded_at: now, |
| 849 | }); |
| 850 | app.push_turn_cache_record(TurnCacheRecord { |
| 851 | provider: None, |
| 852 | provider_identity: None, |
| 853 | model: None, |
| 854 | auto_model: false, |
| 855 | input_tokens: 6_000, |
| 856 | output_tokens: 250, |
| 857 | cache_hit_tokens: Some(3_000), |
| 858 | cache_miss_tokens: Some(3_000), |
| 859 | reasoning_replay_tokens: Some(150), |
| 860 | cache_write_tokens: None, |
| 861 | reasoning_tokens: None, |
| 862 | cost_audit: None, |
| 863 | recorded_at: now, |
| 864 | }); |
| 865 | // Turn 3: hit reported but provider didn't report miss separately — |
| 866 | // infer miss = input − hit and mark with `*`. |
| 867 | app.push_turn_cache_record(TurnCacheRecord { |
| 868 | provider: None, |
| 869 | provider_identity: None, |
| 870 | model: None, |
| 871 | auto_model: false, |
| 872 | input_tokens: 5_000, |
| 873 | output_tokens: 100, |
| 874 | cache_hit_tokens: Some(2_500), |
| 875 | cache_miss_tokens: None, |
| 876 | reasoning_replay_tokens: None, |
| 877 | cache_write_tokens: None, |
| 878 | reasoning_tokens: None, |
| 879 | cost_audit: None, |
| 880 | recorded_at: now, |
| 881 | }); |
| 882 | // Turn 4: no telemetry at all — must not pollute aggregate ratios. |
| 883 | app.push_turn_cache_record(TurnCacheRecord { |
| 884 | provider: None, |
| 885 | provider_identity: None, |
| 886 | model: None, |
| 887 | auto_model: false, |
| 888 | input_tokens: 1_000, |
| 889 | output_tokens: 50, |
| 890 | cache_hit_tokens: None, |
| 891 | cache_miss_tokens: None, |
| 892 | reasoning_replay_tokens: None, |
| 893 | cache_write_tokens: None, |
| 894 | reasoning_tokens: None, |
| 895 | cost_audit: None, |
| 896 | recorded_at: now, |
| 897 | }); |
| 898 | |
| 899 | let result = cache(&mut app, None); |
| 900 | let msg = result.message.expect("cache produces a message"); |
| 901 | |
| 902 | // Header reflects total rows and model. |
| 903 | assert!(msg.contains("last 4 of 4 turn(s)"), "got: {msg}"); |
| 904 | // Per-turn ratios are rendered. |
| 905 | assert!(msg.contains("75.0%"), "got: {msg}"); |
| 906 | assert!(msg.contains("50.0%"), "got: {msg}"); |
| 907 | assert!(msg.contains("auto:deepseek/deepsee..."), "got: {msg}"); |
| 908 | // Turn 3: hit=2500, inferred miss=2500 → 50.0% with `*`-marked miss. |
| 909 | assert!(msg.contains("2500*"), "got: {msg}"); |
| 910 | // Turn 4 (no telemetry) shows em-dashes and is excluded from totals. |
| 911 | // Aggregate over turns 1-3: hit=8500, miss=6500 → 56.7%. |
| 912 | assert!(msg.contains("avg hit ratio: 56.7%"), "got: {msg}"); |
| 913 | // Footer guidance is present. |
| 914 | assert!(msg.contains("70%"), "got: {msg}"); |
| 915 | } |
| 916 | |
| 917 | #[test] |
| 918 | fn cache_history_shows_cache_write_tokens_and_explains_unpriced_turns() { |
| 919 | use crate::pricing::audit_turn_cost_for_provider_at; |
| 920 | |
| 921 | let mut app = create_test_app(); |
| 922 | let write_heavy = codewhale_models::Usage { |
| 923 | input_tokens: 1_000_000, |
| 924 | output_tokens: 100_000, |
| 925 | prompt_cache_hit_tokens: Some(200_000), |
| 926 | prompt_cache_write_tokens: Some(100_000), |
| 927 | ..Default::default() |
| 928 | }; |
| 929 | let now = chrono::Utc::now(); |
| 930 | |
| 931 | app.push_turn_cache_record(TurnCacheRecord { |
| 932 | provider: Some(crate::config::ApiProvider::Anthropic), |
| 933 | provider_identity: None, |
| 934 | model: Some("claude-haiku-4-5".to_string()), |
| 935 | auto_model: false, |
| 936 | input_tokens: 1_000_000, |
| 937 | output_tokens: 100_000, |
| 938 | cache_hit_tokens: Some(200_000), |
| 939 | cache_miss_tokens: Some(700_000), |
| 940 | reasoning_replay_tokens: None, |
| 941 | cache_write_tokens: Some(100_000), |
| 942 | reasoning_tokens: Some(40_000), |
| 943 | cost_audit: Some(audit_turn_cost_for_provider_at( |
| 944 | crate::config::ApiProvider::Anthropic, |
| 945 | "claude-haiku-4-5", |
| 946 | &write_heavy, |
| 947 | now, |
| 948 | )), |
| 949 | recorded_at: Instant::now(), |
| 950 | }); |
| 951 | app.push_turn_cache_record(TurnCacheRecord { |
| 952 | provider: Some(crate::config::ApiProvider::Moonshot), |
| 953 | provider_identity: None, |
| 954 | model: Some("kimi-k2.7-code".to_string()), |
| 955 | auto_model: false, |
| 956 | input_tokens: 1_000_000, |
| 957 | output_tokens: 100_000, |
| 958 | cache_hit_tokens: Some(200_000), |
| 959 | cache_miss_tokens: Some(700_000), |
| 960 | reasoning_replay_tokens: None, |
| 961 | cache_write_tokens: Some(100_000), |
| 962 | reasoning_tokens: Some(10_000), |
| 963 | cost_audit: Some(audit_turn_cost_for_provider_at( |
| 964 | crate::config::ApiProvider::Moonshot, |
| 965 | "kimi-k2.7-code", |
| 966 | &write_heavy, |
| 967 | now, |
| 968 | )), |
| 969 | recorded_at: Instant::now(), |
| 970 | }); |
| 971 | |
| 972 | let msg = cache(&mut app, None).message.expect("cache message"); |
| 973 | |
| 974 | assert!(msg.contains("write"), "{msg}"); |
| 975 | assert!(msg.contains("sum_write: 200000"), "{msg}"); |
| 976 | assert!(msg.contains("sum_reasoning: 50000"), "{msg}"); |
| 977 | // The priced turn shows money; the unpriced one shows why it does not. |
| 978 | assert!(msg.contains("$1.3450"), "{msg}"); |
| 979 | assert!(msg.contains("token class rate unavailable"), "{msg}"); |
| 980 | assert!(msg.contains("cache_write"), "{msg}"); |
| 981 | } |
| 982 | |
| 983 | #[test] |
| 984 | fn cache_command_replays_reported_1177_low_hit_fixture() { |
| 985 | let mut app = create_test_app(); |
| 986 | let now = Instant::now(); |
| 987 | // Fixture from #1177 / douglarek's 2026-05-10 `/cache` report. |
| 988 | // It captures a real low-hit sequence with one 56.8% tail turn. |
| 989 | for (input, output, hit, miss) in [ |
| 990 | (25_839, 12, 4_608, 21_231), |
| 991 | (25_906, 288, 25_728, 178), |
| 992 | (264_500, 2_528, 235_648, 28_852), |
| 993 | (202_230, 3_191, 193_536, 8_694), |
| 994 | (45_982, 294, 26_112, 19_870), |
| 995 | ] { |
| 996 | app.push_turn_cache_record(TurnCacheRecord { |
| 997 | provider: None, |
| 998 | provider_identity: None, |
| 999 | model: None, |
| 1000 | auto_model: false, |
| 1001 | input_tokens: input, |
| 1002 | output_tokens: output, |
| 1003 | cache_hit_tokens: Some(hit), |
| 1004 | cache_miss_tokens: Some(miss), |
| 1005 | reasoning_replay_tokens: None, |
| 1006 | cache_write_tokens: None, |
| 1007 | reasoning_tokens: None, |
| 1008 | cost_audit: None, |
| 1009 | recorded_at: now, |
| 1010 | }); |
| 1011 | } |
| 1012 | |
| 1013 | let result = cache(&mut app, None); |
| 1014 | let msg = result.message.expect("cache produces a message"); |
| 1015 | |
| 1016 | assert!(msg.contains("last 5 of 5 turn(s)"), "got: {msg}"); |
| 1017 | assert!(msg.contains("56.8%"), "got: {msg}"); |
| 1018 | assert!(msg.contains("Σ in: 564457"), "got: {msg}"); |
| 1019 | assert!(msg.contains("Σ hit: 485632"), "got: {msg}"); |
| 1020 | assert!(msg.contains("Σ miss: 78825"), "got: {msg}"); |
| 1021 | assert!(msg.contains("avg hit ratio: 86.0%"), "got: {msg}"); |
| 1022 | } |
| 1023 | |
| 1024 | #[test] |
| 1025 | fn cache_command_count_argument_clamps_to_history() { |
| 1026 | let mut app = create_test_app(); |
| 1027 | for _ in 0..3 { |
| 1028 | app.push_turn_cache_record(TurnCacheRecord { |
| 1029 | provider: None, |
| 1030 | provider_identity: None, |
| 1031 | model: None, |
| 1032 | auto_model: false, |
| 1033 | input_tokens: 1_000, |
| 1034 | output_tokens: 100, |
| 1035 | cache_hit_tokens: Some(500), |
| 1036 | cache_miss_tokens: Some(500), |
| 1037 | reasoning_replay_tokens: None, |
| 1038 | cache_write_tokens: None, |
| 1039 | reasoning_tokens: None, |
| 1040 | cost_audit: None, |
| 1041 | recorded_at: Instant::now(), |
| 1042 | }); |
| 1043 | } |
| 1044 | let result = cache(&mut app, Some("100")); |
| 1045 | let msg = result.message.expect("cache produces a message"); |
| 1046 | // Asked for 100 turns, only 3 exist — should report "last 3 of 3". |
| 1047 | assert!(msg.contains("last 3 of 3 turn(s)"), "got: {msg}"); |
| 1048 | } |
| 1049 | |
| 1050 | #[test] |
| 1051 | fn turn_cache_history_is_capped_at_50() { |
| 1052 | let mut app = create_test_app(); |
| 1053 | for i in 0..(crate::tui::app::App::TURN_CACHE_HISTORY_CAP + 12) { |
| 1054 | app.push_turn_cache_record(TurnCacheRecord { |
| 1055 | provider: None, |
| 1056 | provider_identity: None, |
| 1057 | model: None, |
| 1058 | auto_model: false, |
| 1059 | input_tokens: i as u32, |
| 1060 | output_tokens: 1, |
| 1061 | cache_hit_tokens: Some(i as u32), |
| 1062 | cache_miss_tokens: Some(0), |
| 1063 | reasoning_replay_tokens: None, |
| 1064 | cache_write_tokens: None, |
| 1065 | reasoning_tokens: None, |
| 1066 | cost_audit: None, |
| 1067 | recorded_at: Instant::now(), |
| 1068 | }); |
| 1069 | } |
| 1070 | assert_eq!( |
| 1071 | app.session.turn_cache_history.len(), |
| 1072 | crate::tui::app::App::TURN_CACHE_HISTORY_CAP |
| 1073 | ); |
| 1074 | // Oldest record was evicted; newest record is still at the back. |
| 1075 | assert_eq!( |
| 1076 | app.session.turn_cache_history.back().unwrap().input_tokens, |
| 1077 | (crate::tui::app::App::TURN_CACHE_HISTORY_CAP + 11) as u32 |
| 1078 | ); |
| 1079 | } |
| 1080 | |
| 1081 | #[test] |
| 1082 | fn test_context_shows_usage_stats() { |
| 1083 | let mut app = create_test_app(); |
| 1084 | app.api_messages_mut().push(Message { |
| 1085 | role: Role::User, |
| 1086 | content: vec![ContentBlock::Text { |
| 1087 | text: "Hello".to_string(), |
| 1088 | cache_control: None, |
| 1089 | }], |
| 1090 | }); |
| 1091 | app.history.push(HistoryCell::User { |
| 1092 | content: "Hello".to_string(), |
| 1093 | }); |
| 1094 | |
| 1095 | let result = context(&mut app, None); |
| 1096 | assert!(matches!( |
| 1097 | result.action, |
| 1098 | Some(AppAction::OpenContextInspector) |
| 1099 | )); |
| 1100 | assert!(result.message.is_none()); |
| 1101 | } |
| 1102 | |
| 1103 | #[test] |
| 1104 | fn test_context_report_subcommands_return_source_map() { |
| 1105 | let mut app = create_test_app(); |
| 1106 | app.api_messages_mut().push(Message { |
| 1107 | role: Role::User, |
| 1108 | content: vec![ContentBlock::Text { |
| 1109 | text: "Hello".to_string(), |
| 1110 | cache_control: None, |
| 1111 | }], |
| 1112 | }); |
| 1113 | app.session.last_tool_catalog = Some(vec![test_tool("read_file")]); |
| 1114 | |
| 1115 | let report = context(&mut app, Some("report")) |
| 1116 | .message |
| 1117 | .expect("report text"); |
| 1118 | assert!(report.contains("Context Source Map")); |
| 1119 | assert!(report.contains("Tool schemas")); |
| 1120 | |
| 1121 | let summary = context(&mut app, Some("summary")) |
| 1122 | .message |
| 1123 | .expect("summary text"); |
| 1124 | assert!(summary.contains("Context Summary")); |
| 1125 | |
| 1126 | let json = context(&mut app, Some("json")).message.expect("json text"); |
| 1127 | let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid context json"); |
| 1128 | assert!(!parsed["entries"].as_array().unwrap().is_empty()); |
| 1129 | |
| 1130 | let prompt_json = context(&mut app, Some("prompt-json")) |
| 1131 | .message |
| 1132 | .expect("prompt context json"); |
| 1133 | let prompt: serde_json::Value = |
| 1134 | serde_json::from_str(&prompt_json).expect("valid prompt context json"); |
| 1135 | assert_eq!(prompt["schema_version"], 1); |
| 1136 | assert_eq!(prompt["model"], app.model); |
| 1137 | assert_eq!(prompt["system_prompt_state"], "current_session"); |
| 1138 | assert_eq!(prompt["tool_catalog_state"], "last_sent"); |
| 1139 | assert_eq!(prompt["tools"][0]["name"], "read_file"); |
| 1140 | assert!(prompt["sections"].is_array()); |
| 1141 | assert!( |
| 1142 | !prompt["source_map"]["entries"] |
| 1143 | .as_array() |
| 1144 | .expect("source entries") |
| 1145 | .is_empty() |
| 1146 | ); |
| 1147 | } |
| 1148 | |
| 1149 | #[test] |
| 1150 | fn test_undo_conversation_removes_last_exchange() { |
| 1151 | let mut app = create_test_app(); |
| 1152 | app.history.push(HistoryCell::User { |
| 1153 | content: "Hello".to_string(), |
| 1154 | }); |
| 1155 | app.history.push(HistoryCell::Assistant { |
| 1156 | content: "Hi".to_string(), |
| 1157 | streaming: false, |
| 1158 | }); |
| 1159 | app.api_messages_mut().push(Message { |
| 1160 | role: Role::User, |
| 1161 | content: vec![], |
| 1162 | }); |
| 1163 | app.api_messages_mut().push(Message { |
| 1164 | role: Role::Assistant, |
| 1165 | content: vec![], |
| 1166 | }); |
| 1167 | |
| 1168 | let initial_history_len = app.history.len(); |
| 1169 | let initial_api_len = app.api_messages.len(); |
| 1170 | let result = undo_conversation(&mut app); |
| 1171 | |
| 1172 | assert!(result.message.is_some()); |
| 1173 | let msg = result.message.unwrap(); |
| 1174 | assert!(msg.contains("Removed")); |
| 1175 | assert!(app.history.len() < initial_history_len); |
| 1176 | assert!(app.api_messages.len() < initial_api_len); |
| 1177 | } |
| 1178 | |
| 1179 | #[test] |
| 1180 | fn test_undo_conversation_nothing_to_undo() { |
| 1181 | let mut app = create_test_app(); |
| 1182 | // Clear any default history |
| 1183 | app.history.clear(); |
| 1184 | app.api_messages_mut().clear(); |
| 1185 | let result = undo_conversation(&mut app); |
| 1186 | assert!(result.message.is_some()); |
| 1187 | let msg = result.message.unwrap(); |
| 1188 | assert!(msg.contains("Nothing to undo") || msg.contains("Removed")); |
| 1189 | } |
| 1190 | |
| 1191 | #[test] |
| 1192 | fn test_retry_with_previous_message() { |
| 1193 | let mut app = create_test_app(); |
| 1194 | app.history.push(HistoryCell::User { |
| 1195 | content: "Test message".to_string(), |
| 1196 | }); |
| 1197 | app.history.push(HistoryCell::Assistant { |
| 1198 | content: "Response".to_string(), |
| 1199 | streaming: false, |
| 1200 | }); |
| 1201 | |
| 1202 | let result = retry(&mut app); |
| 1203 | assert!(result.message.is_some()); |
| 1204 | let msg = result.message.unwrap(); |
| 1205 | assert!(msg.contains("Retrying")); |
| 1206 | assert!(msg.contains("Test message")); |
| 1207 | assert!(matches!(result.action, Some(AppAction::SendMessage(_)))); |
| 1208 | } |
| 1209 | |
| 1210 | #[test] |
| 1211 | fn test_retry_no_previous_message() { |
| 1212 | let mut app = create_test_app(); |
| 1213 | let result = retry(&mut app); |
| 1214 | assert!(result.message.is_some()); |
| 1215 | let msg = result.message.unwrap(); |
| 1216 | assert!(msg.contains("No previous request to retry")); |
| 1217 | assert!(result.action.is_none()); |
| 1218 | } |
| 1219 | |
| 1220 | #[test] |
| 1221 | fn test_retry_truncates_long_input() { |
| 1222 | let mut app = create_test_app(); |
| 1223 | let long_input = "x".repeat(100); |
| 1224 | app.history.push(HistoryCell::User { |
| 1225 | content: long_input.clone(), |
| 1226 | }); |
| 1227 | app.history.push(HistoryCell::Assistant { |
| 1228 | content: "Response".to_string(), |
| 1229 | streaming: false, |
| 1230 | }); |
| 1231 | |
| 1232 | let result = retry(&mut app); |
| 1233 | assert!(result.message.is_some()); |
| 1234 | let msg = result.message.unwrap(); |
| 1235 | assert!(msg.contains("Retrying")); |
| 1236 | assert!(msg.contains("...")); |
| 1237 | } |
| 1238 | |
| 1239 | #[test] |
| 1240 | fn test_patch_undo_requests_session_resync_after_restore() { |
| 1241 | use crate::snapshot::SnapshotRepo; |
| 1242 | use crate::test_support::lock_test_env; |
| 1243 | use tempfile::tempdir; |
| 1244 | |
| 1245 | struct HomeGuard { |
| 1246 | prev: Option<std::ffi::OsString>, |
| 1247 | _lock: crate::test_support::TestEnvLock, |
| 1248 | } |
| 1249 | |
| 1250 | impl Drop for HomeGuard { |
| 1251 | fn drop(&mut self) { |
| 1252 | // SAFETY: process-wide lock still held. |
| 1253 | unsafe { |
| 1254 | match self.prev.take() { |
| 1255 | Some(v) => std::env::set_var("HOME", v), |
| 1256 | None => std::env::remove_var("HOME"), |
| 1257 | } |
| 1258 | } |
| 1259 | } |
| 1260 | } |
| 1261 | |
| 1262 | fn scoped_home(home: &std::path::Path) -> HomeGuard { |
| 1263 | let lock = lock_test_env(); |
| 1264 | let prev = std::env::var_os("HOME"); |
| 1265 | // SAFETY: serialized by the global env lock. |
| 1266 | unsafe { |
| 1267 | std::env::set_var("HOME", home); |
| 1268 | } |
| 1269 | HomeGuard { prev, _lock: lock } |
| 1270 | } |
| 1271 | |
| 1272 | let tmp = tempdir().unwrap(); |
| 1273 | let workspace = tmp.path().join("ws"); |
| 1274 | std::fs::create_dir_all(&workspace).unwrap(); |
| 1275 | let _guard = scoped_home(tmp.path()); |
| 1276 | |
| 1277 | let repo = SnapshotRepo::open_or_init(&workspace).unwrap(); |
| 1278 | std::fs::write(workspace.join("a.txt"), b"original").unwrap(); |
| 1279 | repo.snapshot_with_session("pre-turn:1", Some("test-session")) |
| 1280 | .unwrap(); |
| 1281 | std::fs::write(workspace.join("a.txt"), b"modified").unwrap(); |
| 1282 | repo.snapshot_with_session("post-turn:1", Some("test-session")) |
| 1283 | .unwrap(); |
| 1284 | |
| 1285 | let mut app = create_test_app(); |
| 1286 | app.workspace = workspace.clone(); |
| 1287 | app.yolo = true; |
| 1288 | app.current_session_id = Some("test-session".to_string()); |
| 1289 | app.api_messages_mut().push(Message { |
| 1290 | role: Role::User, |
| 1291 | content: vec![ContentBlock::Text { |
| 1292 | text: "please edit a.txt".to_string(), |
| 1293 | cache_control: None, |
| 1294 | }], |
| 1295 | }); |
| 1296 | |
| 1297 | let result = patch_undo(&mut app); |
| 1298 | |
| 1299 | assert!(!result.is_error); |
| 1300 | assert!(matches!( |
| 1301 | result.action, |
| 1302 | Some(AppAction::SyncSession { |
| 1303 | ref messages, |
| 1304 | ref workspace, |
| 1305 | .. |
| 1306 | }) if messages.as_slice() == app.api_messages.as_slice() |
| 1307 | && workspace == &app.workspace |
| 1308 | )); |
| 1309 | } |
| 1310 | |
| 1311 | #[test] |
| 1312 | fn test_undo_legacy_chain_falls_back_to_conversation_only() { |
| 1313 | use crate::snapshot::SnapshotRepo; |
| 1314 | use crate::test_support::lock_test_env; |
| 1315 | use tempfile::tempdir; |
| 1316 | |
| 1317 | struct HomeGuard { |
| 1318 | prev: Option<std::ffi::OsString>, |
| 1319 | _lock: crate::test_support::TestEnvLock, |
| 1320 | } |
| 1321 | |
| 1322 | impl Drop for HomeGuard { |
| 1323 | fn drop(&mut self) { |
| 1324 | // SAFETY: process-wide lock still held. |
| 1325 | unsafe { |
| 1326 | match self.prev.take() { |
| 1327 | Some(v) => std::env::set_var("HOME", v), |
| 1328 | None => std::env::remove_var("HOME"), |
| 1329 | } |
| 1330 | } |
| 1331 | } |
| 1332 | } |
| 1333 | |
| 1334 | fn scoped_home(home: &std::path::Path) -> HomeGuard { |
| 1335 | let lock = lock_test_env(); |
| 1336 | let prev = std::env::var_os("HOME"); |
| 1337 | // SAFETY: serialized by the global env lock. |
| 1338 | unsafe { |
| 1339 | std::env::set_var("HOME", home); |
| 1340 | } |
| 1341 | HomeGuard { prev, _lock: lock } |
| 1342 | } |
| 1343 | |
| 1344 | let tmp = tempdir().unwrap(); |
| 1345 | let workspace = tmp.path().join("ws"); |
| 1346 | std::fs::create_dir_all(&workspace).unwrap(); |
| 1347 | let _guard = scoped_home(tmp.path()); |
| 1348 | |
| 1349 | let repo = SnapshotRepo::open_or_init(&workspace).unwrap(); |
| 1350 | let file = workspace.join("a.txt"); |
| 1351 | std::fs::write(&file, b"zero").unwrap(); |
| 1352 | repo.snapshot("tool:first").unwrap(); |
| 1353 | std::fs::write(&file, b"one").unwrap(); |
| 1354 | repo.snapshot("tool:second").unwrap(); |
| 1355 | std::fs::write(&file, b"two").unwrap(); |
| 1356 | |
| 1357 | let mut app = create_test_app(); |
| 1358 | app.workspace = workspace.clone(); |
| 1359 | app.current_session_id = Some("current-session".to_string()); |
| 1360 | app.history.push(HistoryCell::User { |
| 1361 | content: "chat only".to_string(), |
| 1362 | }); |
| 1363 | app.history.push(HistoryCell::Assistant { |
| 1364 | content: "reply".to_string(), |
| 1365 | streaming: false, |
| 1366 | }); |
| 1367 | |
| 1368 | let result = super::dispatch(&mut app, "undo", None).expect("registered command"); |
| 1369 | assert!(!result.is_error); |
| 1370 | assert!( |
| 1371 | result |
| 1372 | .message |
| 1373 | .as_deref() |
| 1374 | .is_some_and(|m| m.contains("Removed")), |
| 1375 | "expected conversation fallback, got: {:?}", |
| 1376 | result.message |
| 1377 | ); |
| 1378 | assert_eq!(std::fs::read_to_string(&file).unwrap(), "two"); |
| 1379 | } |
| 1380 | |
| 1381 | #[test] |
| 1382 | fn test_patch_undo_prunes_tool_turn_context() { |
| 1383 | use crate::snapshot::SnapshotRepo; |
| 1384 | use crate::test_support::lock_test_env; |
| 1385 | use tempfile::tempdir; |
| 1386 | |
| 1387 | struct HomeGuard { |
| 1388 | prev: Option<std::ffi::OsString>, |
| 1389 | _lock: crate::test_support::TestEnvLock, |
| 1390 | } |
| 1391 | |
| 1392 | impl Drop for HomeGuard { |
| 1393 | fn drop(&mut self) { |
| 1394 | // SAFETY: process-wide lock still held. |
| 1395 | unsafe { |
| 1396 | match self.prev.take() { |
| 1397 | Some(v) => std::env::set_var("HOME", v), |
| 1398 | None => std::env::remove_var("HOME"), |
| 1399 | } |
| 1400 | } |
| 1401 | } |
| 1402 | } |
| 1403 | |
| 1404 | fn scoped_home(home: &std::path::Path) -> HomeGuard { |
| 1405 | let lock = lock_test_env(); |
| 1406 | let prev = std::env::var_os("HOME"); |
| 1407 | // SAFETY: serialized by the global env lock. |
| 1408 | unsafe { |
| 1409 | std::env::set_var("HOME", home); |
| 1410 | } |
| 1411 | HomeGuard { prev, _lock: lock } |
| 1412 | } |
| 1413 | |
| 1414 | let tmp = tempdir().unwrap(); |
| 1415 | let workspace = tmp.path().join("ws"); |
| 1416 | std::fs::create_dir_all(&workspace).unwrap(); |
| 1417 | let _guard = scoped_home(tmp.path()); |
| 1418 | |
| 1419 | let repo = SnapshotRepo::open_or_init(&workspace).unwrap(); |
| 1420 | let file = workspace.join("a.txt"); |
| 1421 | std::fs::write(&file, b"alpha").unwrap(); |
| 1422 | repo.snapshot_with_session("tool:call-1", Some("test-session")) |
| 1423 | .unwrap(); |
| 1424 | std::fs::write(&file, b"alpha-fixed").unwrap(); |
| 1425 | |
| 1426 | let mut app = create_test_app(); |
| 1427 | app.workspace = workspace.clone(); |
| 1428 | app.yolo = true; |
| 1429 | app.current_session_id = Some("test-session".to_string()); |
| 1430 | app.history.push(HistoryCell::User { |
| 1431 | content: "please edit a.txt".to_string(), |
| 1432 | }); |
| 1433 | app.history.push(HistoryCell::Assistant { |
| 1434 | content: "I will update the file.".to_string(), |
| 1435 | streaming: false, |
| 1436 | }); |
| 1437 | app.history |
| 1438 | .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell { |
| 1439 | name: "write_file".to_string(), |
| 1440 | status: ToolStatus::Success, |
| 1441 | input_summary: Some("a.txt".to_string()), |
| 1442 | output: Some("updated".to_string()), |
| 1443 | prompts: None, |
| 1444 | spillover_path: None, |
| 1445 | output_summary: None, |
| 1446 | is_diff: false, |
| 1447 | }))); |
| 1448 | app.history.push(HistoryCell::Assistant { |
| 1449 | content: "Done, file is fixed now.".to_string(), |
| 1450 | streaming: false, |
| 1451 | }); |
| 1452 | app.tool_cells.insert("call-1".to_string(), 2); |
| 1453 | |
| 1454 | app.api_messages_mut().push(Message { |
| 1455 | role: Role::User, |
| 1456 | content: vec![ContentBlock::Text { |
| 1457 | text: "please edit a.txt".to_string(), |
| 1458 | cache_control: None, |
| 1459 | }], |
| 1460 | }); |
| 1461 | app.api_messages_mut().push(Message { |
| 1462 | role: Role::Assistant, |
| 1463 | content: vec![ |
| 1464 | ContentBlock::Text { |
| 1465 | text: "I will update the file.".to_string(), |
| 1466 | cache_control: None, |
| 1467 | }, |
| 1468 | ContentBlock::ToolUse { |
| 1469 | id: "call-1".to_string(), |
| 1470 | name: "write_file".to_string(), |
| 1471 | input: serde_json::json!({"path": "a.txt"}), |
| 1472 | caller: None, |
| 1473 | thought_signature: None, |
| 1474 | }, |
| 1475 | ], |
| 1476 | }); |
| 1477 | app.api_messages_mut().push(Message { |
| 1478 | role: Role::User, |
| 1479 | content: vec![ContentBlock::ToolResult { |
| 1480 | tool_use_id: "call-1".to_string(), |
| 1481 | content: "updated".to_string(), |
| 1482 | is_error: None, |
| 1483 | content_blocks: None, |
| 1484 | }], |
| 1485 | }); |
| 1486 | app.api_messages_mut().push(Message { |
| 1487 | role: Role::Assistant, |
| 1488 | content: vec![ContentBlock::Text { |
| 1489 | text: "Done, file is fixed now.".to_string(), |
| 1490 | cache_control: None, |
| 1491 | }], |
| 1492 | }); |
| 1493 | |
| 1494 | let result = patch_undo(&mut app); |
| 1495 | |
| 1496 | assert!(!result.is_error); |
| 1497 | assert_eq!(std::fs::read_to_string(&file).unwrap(), "alpha"); |
| 1498 | assert_eq!(app.history.len(), 3); |
| 1499 | assert!(matches!( |
| 1500 | app.history.last(), |
| 1501 | Some(HistoryCell::System { content }) if content.contains("/undo reverted workspace") |
| 1502 | )); |
| 1503 | assert_eq!(app.api_messages.len(), 2); |
| 1504 | assert!(matches!( |
| 1505 | &app.api_messages[0].content[0], |
| 1506 | ContentBlock::Text { text, .. } if text == "please edit a.txt" |
| 1507 | )); |
| 1508 | assert_eq!(app.api_messages[1].content.len(), 1); |
| 1509 | assert!(matches!( |
| 1510 | &app.api_messages[1].content[0], |
| 1511 | ContentBlock::Text { text, .. } if text == "I will update the file." |
| 1512 | )); |
| 1513 | } |
| 1514 | |
| 1515 | #[test] |
| 1516 | fn test_patch_undo_prunes_pre_turn_context() { |
| 1517 | use crate::snapshot::SnapshotRepo; |
| 1518 | use crate::test_support::lock_test_env; |
| 1519 | use tempfile::tempdir; |
| 1520 | |
| 1521 | struct HomeGuard { |
| 1522 | prev: Option<std::ffi::OsString>, |
| 1523 | _lock: crate::test_support::TestEnvLock, |
| 1524 | } |
| 1525 | |
| 1526 | impl Drop for HomeGuard { |
| 1527 | fn drop(&mut self) { |
| 1528 | // SAFETY: process-wide lock still held. |
| 1529 | unsafe { |
| 1530 | match self.prev.take() { |
| 1531 | Some(v) => std::env::set_var("HOME", v), |
| 1532 | None => std::env::remove_var("HOME"), |
| 1533 | } |
| 1534 | } |
| 1535 | } |
| 1536 | } |
| 1537 | |
| 1538 | fn scoped_home(home: &std::path::Path) -> HomeGuard { |
| 1539 | let lock = lock_test_env(); |
| 1540 | let prev = std::env::var_os("HOME"); |
| 1541 | // SAFETY: serialized by the global env lock. |
| 1542 | unsafe { |
| 1543 | std::env::set_var("HOME", home); |
| 1544 | } |
| 1545 | HomeGuard { prev, _lock: lock } |
| 1546 | } |
| 1547 | |
| 1548 | let tmp = tempdir().unwrap(); |
| 1549 | let workspace = tmp.path().join("ws"); |
| 1550 | std::fs::create_dir_all(&workspace).unwrap(); |
| 1551 | let _guard = scoped_home(tmp.path()); |
| 1552 | |
| 1553 | let repo = SnapshotRepo::open_or_init(&workspace).unwrap(); |
| 1554 | let file = workspace.join("a.txt"); |
| 1555 | std::fs::write(&file, b"alpha").unwrap(); |
| 1556 | repo.snapshot_with_session("pre-turn:1", Some("test-session")) |
| 1557 | .unwrap(); |
| 1558 | std::fs::write(&file, b"alpha-fixed").unwrap(); |
| 1559 | |
| 1560 | let mut app = create_test_app(); |
| 1561 | app.workspace = workspace.clone(); |
| 1562 | app.yolo = true; |
| 1563 | app.current_session_id = Some("test-session".to_string()); |
| 1564 | app.history.push(HistoryCell::User { |
| 1565 | content: "please edit a.txt".to_string(), |
| 1566 | }); |
| 1567 | app.history.push(HistoryCell::Assistant { |
| 1568 | content: "Done, file is fixed now.".to_string(), |
| 1569 | streaming: false, |
| 1570 | }); |
| 1571 | app.api_messages_mut().push(Message { |
| 1572 | role: Role::User, |
| 1573 | content: vec![ContentBlock::Text { |
| 1574 | text: "please edit a.txt".to_string(), |
| 1575 | cache_control: None, |
| 1576 | }], |
| 1577 | }); |
| 1578 | app.api_messages_mut().push(Message { |
| 1579 | role: Role::Assistant, |
| 1580 | content: vec![ContentBlock::Text { |
| 1581 | text: "Done, file is fixed now.".to_string(), |
| 1582 | cache_control: None, |
| 1583 | }], |
| 1584 | }); |
| 1585 | |
| 1586 | let result = patch_undo(&mut app); |
| 1587 | |
| 1588 | assert!(!result.is_error); |
| 1589 | assert_eq!(std::fs::read_to_string(&file).unwrap(), "alpha"); |
| 1590 | assert_eq!(app.history.len(), 1); |
| 1591 | assert!(matches!( |
| 1592 | app.history.last(), |
| 1593 | Some(HistoryCell::System { content }) if content.contains("/undo reverted workspace") |
| 1594 | )); |
| 1595 | assert!(app.api_messages.is_empty()); |
| 1596 | } |
| 1597 | |
| 1598 | #[test] |
| 1599 | fn test_prune_undone_tool_context_preserves_prior_tool_pairs() { |
| 1600 | let mut app = create_test_app(); |
| 1601 | app.history.push(HistoryCell::User { |
| 1602 | content: "edit two files".to_string(), |
| 1603 | }); |
| 1604 | app.history.push(HistoryCell::Assistant { |
| 1605 | content: "I will update both files.".to_string(), |
| 1606 | streaming: false, |
| 1607 | }); |
| 1608 | app.history |
| 1609 | .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell { |
| 1610 | name: "write_file".to_string(), |
| 1611 | status: ToolStatus::Success, |
| 1612 | input_summary: Some("a.txt".to_string()), |
| 1613 | output: Some("updated a".to_string()), |
| 1614 | prompts: None, |
| 1615 | spillover_path: None, |
| 1616 | output_summary: None, |
| 1617 | is_diff: false, |
| 1618 | }))); |
| 1619 | app.history |
| 1620 | .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell { |
| 1621 | name: "write_file".to_string(), |
| 1622 | status: ToolStatus::Success, |
| 1623 | input_summary: Some("b.txt".to_string()), |
| 1624 | output: Some("updated b".to_string()), |
| 1625 | prompts: None, |
| 1626 | spillover_path: None, |
| 1627 | output_summary: None, |
| 1628 | is_diff: false, |
| 1629 | }))); |
| 1630 | app.history.push(HistoryCell::Assistant { |
| 1631 | content: "Done.".to_string(), |
| 1632 | streaming: false, |
| 1633 | }); |
| 1634 | app.tool_cells.insert("call-a".to_string(), 2); |
| 1635 | app.tool_cells.insert("call-b".to_string(), 3); |
| 1636 | |
| 1637 | app.api_messages_mut().push(Message { |
| 1638 | role: Role::User, |
| 1639 | content: vec![ContentBlock::Text { |
| 1640 | text: "edit two files".to_string(), |
| 1641 | cache_control: None, |
| 1642 | }], |
| 1643 | }); |
| 1644 | app.api_messages_mut().push(Message { |
| 1645 | role: Role::Assistant, |
| 1646 | content: vec![ |
| 1647 | ContentBlock::Text { |
| 1648 | text: "I will update both files.".to_string(), |
| 1649 | cache_control: None, |
| 1650 | }, |
| 1651 | ContentBlock::ToolUse { |
| 1652 | id: "call-a".to_string(), |
| 1653 | name: "write_file".to_string(), |
| 1654 | input: serde_json::json!({"path": "a.txt"}), |
| 1655 | caller: None, |
| 1656 | thought_signature: None, |
| 1657 | }, |
| 1658 | ContentBlock::ToolUse { |
| 1659 | id: "call-b".to_string(), |
| 1660 | name: "write_file".to_string(), |
| 1661 | input: serde_json::json!({"path": "b.txt"}), |
| 1662 | caller: None, |
| 1663 | thought_signature: None, |
| 1664 | }, |
| 1665 | ], |
| 1666 | }); |
| 1667 | app.api_messages_mut().push(Message { |
| 1668 | role: Role::User, |
| 1669 | content: vec![ContentBlock::ToolResult { |
| 1670 | tool_use_id: "call-a".to_string(), |
| 1671 | content: "updated a".to_string(), |
| 1672 | is_error: None, |
| 1673 | content_blocks: None, |
| 1674 | }], |
| 1675 | }); |
| 1676 | app.api_messages_mut().push(Message { |
| 1677 | role: Role::User, |
| 1678 | content: vec![ContentBlock::ToolResult { |
| 1679 | tool_use_id: "call-b".to_string(), |
| 1680 | content: "updated b".to_string(), |
| 1681 | is_error: None, |
| 1682 | content_blocks: None, |
| 1683 | }], |
| 1684 | }); |
| 1685 | app.api_messages_mut().push(Message { |
| 1686 | role: Role::Assistant, |
| 1687 | content: vec![ContentBlock::Text { |
| 1688 | text: "Done.".to_string(), |
| 1689 | cache_control: None, |
| 1690 | }], |
| 1691 | }); |
| 1692 | |
| 1693 | prune_undone_tool_context(&mut app, "call-b"); |
| 1694 | |
| 1695 | assert_eq!(app.history.len(), 3); |
| 1696 | assert_eq!(app.api_messages.len(), 3); |
| 1697 | assert!(matches!( |
| 1698 | &app.api_messages[1].content[..], |
| 1699 | [ |
| 1700 | ContentBlock::Text { .. }, |
| 1701 | ContentBlock::ToolUse { id, ..} |
| 1702 | ] if id == "call-a" |
| 1703 | )); |
| 1704 | assert!(matches!( |
| 1705 | &app.api_messages[2].content[0], |
| 1706 | ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == "call-a" |
| 1707 | )); |
| 1708 | } |
| 1709 | |
| 1710 | // ── /cache stats tests ────────────────────────────────────────────── |
| 1711 | |
| 1712 | #[test] |
| 1713 | fn cache_stats_no_data_before_first_turn() { |
| 1714 | let mut app = create_test_app(); |
| 1715 | let result = cache(&mut app, Some("stats")); |
| 1716 | let msg = result.message.expect("cache stats produces a message"); |
| 1717 | assert!(msg.contains("Cache Stats"), "got: {msg}"); |
| 1718 | assert!( |
| 1719 | msg.contains("unknown (no checks recorded yet)"), |
| 1720 | "got: {msg}" |
| 1721 | ); |
| 1722 | assert!(msg.contains("Pinned hash: unavailable"), "got: {msg}"); |
| 1723 | assert!(msg.contains("No turn telemetry recorded yet"), "got: {msg}"); |
| 1724 | } |
| 1725 | |
| 1726 | #[test] |
| 1727 | fn cache_stats_shows_stable_prefix_with_hash() { |
| 1728 | let mut app = create_test_app(); |
| 1729 | app.prefix_stability_pct = Some(100); |
| 1730 | app.prefix_checks_total = 5; |
| 1731 | app.prefix_change_count = 0; |
| 1732 | app.last_pinned_prefix_hash = |
| 1733 | Some("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2".to_string()); |
| 1734 | |
| 1735 | let result = cache(&mut app, Some("stats")); |
| 1736 | let msg = result.message.expect("cache stats produces a message"); |
| 1737 | |
| 1738 | assert!(msg.contains("Stability: 100%"), "got: {msg}"); |
| 1739 | assert!(msg.contains("stable (no prefix changes"), "got: {msg}"); |
| 1740 | assert!(msg.contains("Pinned hash: a1b2c3d4e5f6"), "got: {msg}"); |
| 1741 | assert!( |
| 1742 | msg.contains("Drift: none (hash stable)"), |
| 1743 | "got: {msg}" |
| 1744 | ); |
| 1745 | } |
| 1746 | |
| 1747 | #[test] |
| 1748 | fn cache_stats_warns_on_prefix_change() { |
| 1749 | let mut app = create_test_app(); |
| 1750 | app.prefix_stability_pct = Some(67); |
| 1751 | app.prefix_checks_total = 3; |
| 1752 | app.prefix_change_count = 1; |
| 1753 | // The one change was an undeclared drift — the case worth warning about. |
| 1754 | app.prefix_drift_count = 1; |
| 1755 | app.prefix_pin_reason = Some("initial".to_string()); |
| 1756 | app.prefix_last_miss_reason = Some("drift:sys".to_string()); |
| 1757 | app.last_prefix_change_desc = |
| 1758 | Some("drift — prefix cache invalidated: system prompt changed".to_string()); |
| 1759 | app.last_pinned_prefix_hash = |
| 1760 | Some("deadbeef0000deadbeef0000deadbeef0000deadbeef0000deadbeef0000deadbeef".to_string()); |
| 1761 | |
| 1762 | let result = cache(&mut app, Some("stats")); |
| 1763 | let msg = result.message.expect("cache stats produces a message"); |
| 1764 | |
| 1765 | assert!(msg.contains("Stability: 67%"), "got: {msg}"); |
| 1766 | assert!(msg.contains("WARNING — 1 undeclared drift"), "got: {msg}"); |
| 1767 | assert!(msg.contains("Last miss: drift:sys"), "got: {msg}"); |
| 1768 | assert!(msg.contains("system prompt changed"), "got: {msg}"); |
| 1769 | assert!(msg.contains("1 change detected"), "got: {msg}"); |
| 1770 | } |
| 1771 | |
| 1772 | #[test] |
| 1773 | fn cache_stats_does_not_warn_on_declared_header_change() { |
| 1774 | let mut app = create_test_app(); |
| 1775 | app.prefix_stability_pct = Some(67); |
| 1776 | app.prefix_checks_total = 3; |
| 1777 | app.prefix_change_count = 1; |
| 1778 | // A declared header change (e.g. /model): expected, not drift. |
| 1779 | app.prefix_drift_count = 0; |
| 1780 | app.prefix_pin_reason = Some("change:model".to_string()); |
| 1781 | app.prefix_last_miss_reason = Some("change:model".to_string()); |
| 1782 | app.last_prefix_change_desc = Some("change:model — tool set changed".to_string()); |
| 1783 | app.last_pinned_prefix_hash = |
| 1784 | Some("deadbeef0000deadbeef0000deadbeef0000deadbeef0000deadbeef0000deadbeef".to_string()); |
| 1785 | |
| 1786 | let result = cache(&mut app, Some("stats")); |
| 1787 | let msg = result.message.expect("cache stats produces a message"); |
| 1788 | |
| 1789 | assert!( |
| 1790 | msg.contains("stable (all changes were declared header changes)"), |
| 1791 | "got: {msg}" |
| 1792 | ); |
| 1793 | assert!(!msg.contains("WARNING — "), "got: {msg}"); |
| 1794 | assert!(msg.contains("Pin reason: change:model"), "got: {msg}"); |
| 1795 | } |
| 1796 | |
| 1797 | #[test] |
| 1798 | fn cache_stats_shows_cache_hit_summary() { |
| 1799 | let mut app = create_test_app(); |
| 1800 | app.prefix_stability_pct = Some(100); |
| 1801 | app.prefix_checks_total = 1; |
| 1802 | app.last_pinned_prefix_hash = |
| 1803 | Some("abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234".to_string()); |
| 1804 | |
| 1805 | app.push_turn_cache_record(TurnCacheRecord { |
| 1806 | provider: None, |
| 1807 | provider_identity: None, |
| 1808 | model: None, |
| 1809 | auto_model: false, |
| 1810 | input_tokens: 10_000, |
| 1811 | output_tokens: 1_000, |
| 1812 | cache_hit_tokens: Some(8_000), |
| 1813 | cache_miss_tokens: Some(2_000), |
| 1814 | reasoning_replay_tokens: None, |
| 1815 | cache_write_tokens: None, |
| 1816 | reasoning_tokens: None, |
| 1817 | cost_audit: None, |
| 1818 | recorded_at: Instant::now(), |
| 1819 | }); |
| 1820 | app.push_turn_cache_record(TurnCacheRecord { |
| 1821 | provider: None, |
| 1822 | provider_identity: None, |
| 1823 | model: None, |
| 1824 | auto_model: false, |
| 1825 | input_tokens: 5_000, |
| 1826 | output_tokens: 500, |
| 1827 | cache_hit_tokens: Some(4_500), |
| 1828 | cache_miss_tokens: Some(500), |
| 1829 | reasoning_replay_tokens: None, |
| 1830 | cache_write_tokens: None, |
| 1831 | reasoning_tokens: None, |
| 1832 | cost_audit: None, |
| 1833 | recorded_at: Instant::now(), |
| 1834 | }); |
| 1835 | |
| 1836 | let result = cache(&mut app, Some("stats")); |
| 1837 | let msg = result.message.expect("cache stats produces a message"); |
| 1838 | |
| 1839 | assert!(msg.contains("Turns recorded: 2"), "got: {msg}"); |
| 1840 | // Total: 12,500 hit out of 15,000 cache-aware = 83.3% |
| 1841 | assert!(msg.contains("83.3%"), "got: {msg}"); |
| 1842 | } |
| 1843 | |
| 1844 | #[test] |
| 1845 | fn cache_stats_low_hit_rate_shows_note() { |
| 1846 | let mut app = create_test_app(); |
| 1847 | app.prefix_stability_pct = Some(100); |
| 1848 | app.prefix_checks_total = 1; |
| 1849 | app.last_pinned_prefix_hash = |
| 1850 | Some("abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234".to_string()); |
| 1851 | |
| 1852 | app.push_turn_cache_record(TurnCacheRecord { |
| 1853 | provider: None, |
| 1854 | provider_identity: None, |
| 1855 | model: None, |
| 1856 | auto_model: false, |
| 1857 | input_tokens: 10_000, |
| 1858 | output_tokens: 1_000, |
| 1859 | cache_hit_tokens: Some(1_000), |
| 1860 | cache_miss_tokens: Some(9_000), |
| 1861 | reasoning_replay_tokens: None, |
| 1862 | cache_write_tokens: None, |
| 1863 | reasoning_tokens: None, |
| 1864 | cost_audit: None, |
| 1865 | recorded_at: Instant::now(), |
| 1866 | }); |
| 1867 | |
| 1868 | let result = cache(&mut app, Some("stats")); |
| 1869 | let msg = result.message.expect("cache stats produces a message"); |
| 1870 | |
| 1871 | // 10% hit rate → below 80% threshold |
| 1872 | assert!(msg.contains("10.0%"), "got: {msg}"); |
| 1873 | assert!( |
| 1874 | msg.contains("cache hit rate is low"), |
| 1875 | "should show low-hit-rate advisory, got: {msg}" |
| 1876 | ); |
| 1877 | } |
| 1878 | |
| 1879 | #[test] |
| 1880 | fn cache_stats_flags_reported_1747_low_hit_fixture() { |
| 1881 | let mut app = create_test_app(); |
| 1882 | app.prefix_stability_pct = Some(100); |
| 1883 | app.prefix_checks_total = 1; |
| 1884 | app.last_pinned_prefix_hash = |
| 1885 | Some("abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234".to_string()); |
| 1886 | |
| 1887 | // Fixture from #1747 / Amund's DeepSeek-TUI session aggregate: |
| 1888 | // hit=21,356,928, miss=8,470,281, output=165,624. |
| 1889 | app.push_turn_cache_record(TurnCacheRecord { |
| 1890 | provider: None, |
| 1891 | provider_identity: None, |
| 1892 | model: None, |
| 1893 | auto_model: false, |
| 1894 | input_tokens: 29_827_209, |
| 1895 | output_tokens: 165_624, |
| 1896 | cache_hit_tokens: Some(21_356_928), |
| 1897 | cache_miss_tokens: Some(8_470_281), |
| 1898 | reasoning_replay_tokens: None, |
| 1899 | cache_write_tokens: None, |
| 1900 | reasoning_tokens: None, |
| 1901 | cost_audit: None, |
| 1902 | recorded_at: Instant::now(), |
| 1903 | }); |
| 1904 | |
| 1905 | let result = cache(&mut app, Some("stats")); |
| 1906 | let msg = result.message.expect("cache stats produces a message"); |
| 1907 | |
| 1908 | assert!(msg.contains("71.6%"), "got: {msg}"); |
| 1909 | assert!(msg.contains("Cache hit tokens: 21.4M"), "got: {msg}"); |
| 1910 | assert!(msg.contains("Cache miss tokens: 8.5M"), "got: {msg}"); |
| 1911 | assert!( |
| 1912 | msg.contains("cache hit rate is low"), |
| 1913 | "reported #1747 fixture should remain below the advisory threshold: {msg}" |
| 1914 | ); |
| 1915 | } |
| 1916 | |
| 1917 | #[test] |
| 1918 | fn format_tokens_handles_all_scales() { |
| 1919 | assert_eq!(format_tokens(0), "0"); |
| 1920 | assert_eq!(format_tokens(999), "999"); |
| 1921 | assert_eq!(format_tokens(1_000), "1.0K"); |
| 1922 | assert_eq!(format_tokens(15_500), "15.5K"); |
| 1923 | assert_eq!(format_tokens(1_000_000), "1.0M"); |
| 1924 | assert_eq!(format_tokens(2_500_000), "2.5M"); |
| 1925 | } |
| 1926 | |
| 1927 | #[test] |
| 1928 | fn tools_command_is_truthful_before_any_request_snapshot() { |
| 1929 | let mut app = create_test_app(); |
| 1930 | let result = super::dispatch(&mut app, "tools", None).expect("registered tools command"); |
| 1931 | |
| 1932 | assert!(!result.is_error); |
| 1933 | assert!( |
| 1934 | result |
| 1935 | .message |
| 1936 | .expect("message") |
| 1937 | .contains("snapshot unavailable — no model request has been captured") |
| 1938 | ); |
| 1939 | } |
| 1940 | |
| 1941 | #[test] |
| 1942 | fn tools_command_and_compatibility_alias_render_same_exact_snapshot() { |
| 1943 | let mut app = create_test_app(); |
| 1944 | app.session.last_tool_request_snapshot = Some( |
| 1945 | crate::tool_inspection::ToolInspectionSnapshot::from_prepared_request( |
| 1946 | "turn-1", |
| 1947 | 2, |
| 1948 | Some(&[test_tool("read_file")]), |
| 1949 | ), |
| 1950 | ); |
| 1951 | |
| 1952 | let primary = super::dispatch(&mut app, "tools", Some("json")).expect("primary command"); |
| 1953 | let alias = |
| 1954 | super::dispatch(&mut app, "tool-studio", Some("json")).expect("compatibility alias"); |
| 1955 | let primary = match primary.action.expect("primary pager") { |
| 1956 | AppAction::OpenTextPager { content, .. } => content, |
| 1957 | other => panic!("unexpected primary action: {other:?}"), |
| 1958 | }; |
| 1959 | let alias = match alias.action.expect("alias pager") { |
| 1960 | AppAction::OpenTextPager { content, .. } => content, |
| 1961 | other => panic!("unexpected alias action: {other:?}"), |
| 1962 | }; |
| 1963 | |
| 1964 | assert_eq!(primary, alias); |
| 1965 | let parsed: serde_json::Value = serde_json::from_str(&primary).expect("valid JSON output"); |
| 1966 | assert_eq!(parsed["turn_id"]["value"], "turn-1"); |
| 1967 | assert_eq!(parsed["step"], 2); |
| 1968 | assert_eq!(parsed["tool_count"], 1); |
| 1969 | assert_eq!(parsed["tools"][0]["name"]["value"], "read_file"); |
| 1970 | } |
| 1971 | |
| 1972 | #[test] |
| 1973 | fn tools_command_rejects_unknown_formats_without_mutating_state() { |
| 1974 | let mut app = create_test_app(); |
| 1975 | app.session.last_tool_request_snapshot = Some( |
| 1976 | crate::tool_inspection::ToolInspectionSnapshot::from_prepared_request( |
| 1977 | "turn-1", |
| 1978 | 1, |
| 1979 | Some(&[]), |
| 1980 | ), |
| 1981 | ); |
| 1982 | let before = app.session.last_tool_request_snapshot.clone(); |
| 1983 | |
| 1984 | let result = super::dispatch(&mut app, "tools", Some("yaml")).expect("tools command"); |
| 1985 | |
| 1986 | assert!(result.is_error); |
| 1987 | assert_eq!(app.session.last_tool_request_snapshot, before); |
| 1988 | } |
| 1989 | |
| 1990 | #[test] |
| 1991 | fn test_patch_undo_refuses_outside_trusted_mode() { |
| 1992 | use crate::snapshot::SnapshotRepo; |
| 1993 | use crate::test_support::lock_test_env; |
| 1994 | use tempfile::tempdir; |
| 1995 | |
| 1996 | struct HomeGuard { |
| 1997 | prev: Option<std::ffi::OsString>, |
| 1998 | _lock: crate::test_support::TestEnvLock, |
| 1999 | } |
| 2000 | impl Drop for HomeGuard { |
| 2001 | fn drop(&mut self) { |
| 2002 | // SAFETY: process-wide lock still held. |
| 2003 | unsafe { |
| 2004 | match self.prev.take() { |
| 2005 | Some(v) => std::env::set_var("HOME", v), |
| 2006 | None => std::env::remove_var("HOME"), |
| 2007 | } |
| 2008 | } |
| 2009 | } |
| 2010 | } |
| 2011 | fn scoped_home(home: &std::path::Path) -> HomeGuard { |
| 2012 | let lock = lock_test_env(); |
| 2013 | let prev = std::env::var_os("HOME"); |
| 2014 | // SAFETY: serialised by the global env lock. |
| 2015 | unsafe { |
| 2016 | std::env::set_var("HOME", home); |
| 2017 | } |
| 2018 | HomeGuard { prev, _lock: lock } |
| 2019 | } |
| 2020 | |
| 2021 | let tmp = tempdir().unwrap(); |
| 2022 | let workspace = tmp.path().join("ws"); |
| 2023 | std::fs::create_dir_all(&workspace).unwrap(); |
| 2024 | let _guard = scoped_home(tmp.path()); |
| 2025 | |
| 2026 | let repo = SnapshotRepo::open_or_init(&workspace).unwrap(); |
| 2027 | std::fs::write(workspace.join("a.txt"), b"original").unwrap(); |
| 2028 | repo.snapshot_with_session("pre-turn:1", Some("test-session")) |
| 2029 | .unwrap(); |
| 2030 | std::fs::write(workspace.join("a.txt"), b"modified").unwrap(); |
| 2031 | |
| 2032 | // yolo/trust_mode stay false (create_test_app defaults). |
| 2033 | let mut app = create_test_app(); |
| 2034 | app.workspace = workspace.clone(); |
| 2035 | app.current_session_id = Some("test-session".to_string()); |
| 2036 | |
| 2037 | let result = patch_undo(&mut app); |
| 2038 | assert!(!result.is_error); |
| 2039 | assert!( |
| 2040 | result |
| 2041 | .message |
| 2042 | .as_deref() |
| 2043 | .is_some_and(|m| m.contains("Refusing to undo workspace files")), |
| 2044 | "expected refusal message, got: {:?}", |
| 2045 | result.message |
| 2046 | ); |
| 2047 | // Workspace must be untouched by the gate. |
| 2048 | assert_eq!( |
| 2049 | std::fs::read_to_string(workspace.join("a.txt")).unwrap(), |
| 2050 | "modified" |
| 2051 | ); |
| 2052 | } |
| 2053 | |
| 2054 | #[test] |
| 2055 | fn test_patch_undo_never_crosses_session_boundary() { |
| 2056 | use crate::snapshot::SnapshotRepo; |
| 2057 | use crate::test_support::lock_test_env; |
| 2058 | use tempfile::tempdir; |
| 2059 | |
| 2060 | struct HomeGuard { |
| 2061 | prev: Option<std::ffi::OsString>, |
| 2062 | _lock: crate::test_support::TestEnvLock, |
| 2063 | } |
| 2064 | impl Drop for HomeGuard { |
| 2065 | fn drop(&mut self) { |
| 2066 | // SAFETY: process-wide lock still held. |
| 2067 | unsafe { |
| 2068 | match self.prev.take() { |
| 2069 | Some(v) => std::env::set_var("HOME", v), |
| 2070 | None => std::env::remove_var("HOME"), |
| 2071 | } |
| 2072 | } |
| 2073 | } |
| 2074 | } |
| 2075 | fn scoped_home(home: &std::path::Path) -> HomeGuard { |
| 2076 | let lock = lock_test_env(); |
| 2077 | let prev = std::env::var_os("HOME"); |
| 2078 | // SAFETY: serialised by the global env lock. |
| 2079 | unsafe { |
| 2080 | std::env::set_var("HOME", home); |
| 2081 | } |
| 2082 | HomeGuard { prev, _lock: lock } |
| 2083 | } |
| 2084 | |
| 2085 | let tmp = tempdir().unwrap(); |
| 2086 | let workspace = tmp.path().join("ws"); |
| 2087 | std::fs::create_dir_all(&workspace).unwrap(); |
| 2088 | let _guard = scoped_home(tmp.path()); |
| 2089 | |
| 2090 | let repo = SnapshotRepo::open_or_init(&workspace).unwrap(); |
| 2091 | let file = workspace.join("a.txt"); |
| 2092 | |
| 2093 | // Session A: an earlier conversation that modified the workspace. |
| 2094 | std::fs::write(&file, b"a-before").unwrap(); |
| 2095 | repo.snapshot_with_session("pre-turn:1", Some("session-a")) |
| 2096 | .unwrap(); |
| 2097 | std::fs::write(&file, b"a-after").unwrap(); |
| 2098 | |
| 2099 | // Session B (current): a later conversation that also modified it. |
| 2100 | std::fs::write(&file, b"b-before").unwrap(); |
| 2101 | repo.snapshot_with_session("pre-turn:1", Some("session-b")) |
| 2102 | .unwrap(); |
| 2103 | std::fs::write(&file, b"b-after").unwrap(); |
| 2104 | |
| 2105 | let mut app = create_test_app(); |
| 2106 | app.workspace = workspace.clone(); |
| 2107 | app.yolo = true; |
| 2108 | app.current_session_id = Some("session-b".to_string()); |
| 2109 | |
| 2110 | let result = patch_undo(&mut app); |
| 2111 | assert!(!result.is_error); |
| 2112 | // Must restore session B's pre-turn state — never session A's. |
| 2113 | assert_eq!(std::fs::read_to_string(&file).unwrap(), "b-before"); |
| 2114 | |
| 2115 | let repeated = patch_undo(&mut app); |
| 2116 | assert!(!repeated.is_error); |
| 2117 | assert!( |
| 2118 | repeated |
| 2119 | .message |
| 2120 | .as_deref() |
| 2121 | .is_some_and(|m| m.contains("No undoable snapshot")), |
| 2122 | "repeated undo must stop at the session boundary: {:?}", |
| 2123 | repeated.message |
| 2124 | ); |
| 2125 | assert_eq!(std::fs::read_to_string(&file).unwrap(), "b-before"); |
| 2126 | } |
| 2127 | |
| 2128 | /// `/undo` used to report only `Removed N message(s)` when the snapshot repo |
| 2129 | /// could not be opened, implying a file rollback that never happened. The |
| 2130 | /// conversation-only fallback must say so, and say why. |
| 2131 | #[test] |
| 2132 | fn test_undo_reports_that_files_were_not_reverted_when_the_repo_is_unavailable() { |
| 2133 | use crate::test_support::{EnvVarGuard, lock_test_env}; |
| 2134 | use tempfile::tempdir; |
| 2135 | |
| 2136 | let _lock = lock_test_env(); |
| 2137 | let tmp = tempdir().unwrap(); |
| 2138 | let _home = EnvVarGuard::set("HOME", tmp.path()); |
| 2139 | let _profile = EnvVarGuard::set("USERPROFILE", tmp.path()); |
| 2140 | |
| 2141 | // The home directory itself is refused by the snapshot safety gate, so |
| 2142 | // `patch_undo` cannot open a repo at all. |
| 2143 | let mut app = create_test_app(); |
| 2144 | app.workspace = tmp.path().to_path_buf(); |
| 2145 | app.current_session_id = Some("test-session".to_string()); |
| 2146 | app.yolo = true; |
| 2147 | app.history.push(HistoryCell::User { |
| 2148 | content: "change something".to_string(), |
| 2149 | }); |
| 2150 | app.api_messages_mut().push(Message { |
| 2151 | role: Role::User, |
| 2152 | content: vec![ContentBlock::Text { |
| 2153 | text: "change something".to_string(), |
| 2154 | cache_control: None, |
| 2155 | }], |
| 2156 | }); |
| 2157 | |
| 2158 | let result = super::dispatch(&mut app, "undo", None).expect("undo is dispatched here"); |
| 2159 | let message = result.message.as_deref().unwrap_or_default(); |
| 2160 | assert!( |
| 2161 | message.contains("Removed 1 message(s)"), |
| 2162 | "conversation undo still runs: {message}" |
| 2163 | ); |
| 2164 | assert!( |
| 2165 | message.contains(super::undo::FILES_NOT_REVERTED_NOTE), |
| 2166 | "the user must be told files were not reverted: {message}" |
| 2167 | ); |
| 2168 | assert!( |
| 2169 | message.contains(super::undo::SNAPSHOT_REPO_UNAVAILABLE_PREFIX), |
| 2170 | "the reason must travel with the fallback: {message}" |
| 2171 | ); |
| 2172 | } |
| 2173 |