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