返回 CodeWhale
debug_diagnostics_host_tests.rs
根目录 / crates / tui / src / commands / debug_diagnostics_host_tests.rs
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 &current.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), &current);
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), &current);
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
1916 lines RUST