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