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