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