返回 CodeWhale
tests.rs
根目录 / crates / tui / src / tui / app / tests.rs
1 use super::*;
2 use crate::config::{ApiProvider, Config, ProviderConfig, ProvidersConfig};
3 use crate::settings::Settings;
4 use crate::test_support::{EnvVarGuard, lock_test_env};
5 use crate::tools::plan::{PlanItemArg, StepStatus, UpdatePlanArgs};
6 use crate::tools::todo::TodoStatus;
7 use crate::tui::clipboard::{ClipboardHandler, PastedImage};
8 use crate::tui::history::{GenericToolCell, HistoryCell, ToolCell, ToolStatus};
9 use crate::tui::motion::MotionMode;
10
11 fn test_options(yolo: bool) -> TuiOptions {
12 TuiOptions {
13 model: "test-model".to_string(),
14 allow_shell: yolo,
15 // Keep unit tests independent from the developer's saved
16 // `default_mode` setting.
17 start_in_agent_mode: true,
18 skip_onboarding: false,
19 yolo,
20 ..crate::test_support::test_tui_options(PathBuf::from("."))
21 }
22 }
23
24 #[test]
25 fn app_motion_policy_and_transcript_bridge_cover_every_settings_mode() {
26 let mut app = App::new(test_options(false), &Config::default());
27 app.constrained_frame_rate = false;
28
29 for (low_motion, fancy_animations, expected_mode, static_status) in [
30 (false, true, MotionMode::Full, false),
31 (true, true, MotionMode::Reduced, true),
32 (false, false, MotionMode::Still, true),
33 // The explicit accessibility preference wins when both switches are off.
34 (true, false, MotionMode::Reduced, true),
35 ] {
36 app.low_motion = low_motion;
37 app.fancy_animations = fancy_animations;
38
39 assert_eq!(app.motion_policy().mode(), expected_mode);
40 assert_eq!(app.effective_low_motion_for_status(), static_status);
41 let options = app.transcript_render_options();
42 assert_eq!(options.low_motion, static_status);
43 assert_eq!(options.motion_mode, expected_mode);
44 }
45 }
46
47 #[cfg(unix)]
48 fn create_dir_symlink(target: &std::path::Path, link: &std::path::Path) -> std::io::Result<()> {
49 std::os::unix::fs::symlink(target, link)
50 }
51
52 #[cfg(windows)]
53 fn create_dir_symlink(target: &std::path::Path, link: &std::path::Path) -> std::io::Result<()> {
54 std::os::windows::fs::symlink_dir(target, link)
55 }
56
57 #[test]
58 fn feature_intro_is_silent_while_onboarding_is_in_progress() {
59 let mut app = App::new(test_options(false), &Config::default());
60 app.onboarding = OnboardingState::Welcome;
61 let before = app.history.len();
62 app.maybe_show_feature_intro();
63 assert_eq!(
64 app.history.len(),
65 before,
66 "must not nudge while onboarding is in progress"
67 );
68 }
69
70 #[test]
71 fn feature_intro_is_silent_when_auth_setup_is_incomplete() {
72 // --skip-onboarding with no provider key must not claim setup is ready (#3985).
73 let mut app = App::new(test_options(false), &Config::default());
74 app.onboarding = OnboardingState::None;
75 app.onboarding_needs_api_key = true;
76 let before = app.history.len();
77 app.maybe_show_feature_intro();
78 assert_eq!(
79 app.history.len(),
80 before,
81 "must not show 'setup is ready' when API key / auth is missing"
82 );
83 }
84
85 #[test]
86 fn feature_intro_shows_once_persists_then_is_idempotent() {
87 let _env_lock = lock_test_env();
88 let tmp = std::env::temp_dir().join(format!("cw-feature-intro-{}", std::process::id()));
89 let _ = std::fs::remove_dir_all(&tmp);
90 std::fs::create_dir_all(&tmp).unwrap();
91 let config_path = tmp.join("config.toml");
92 let _env = EnvVarGuard::set(
93 "DEEPSEEK_CONFIG_PATH",
94 config_path.to_string_lossy().as_ref(),
95 );
96 let _ = std::fs::remove_file(tmp.join("settings.toml"));
97
98 let mut app = App::new(test_options(false), &Config::default());
99 app.onboarding = OnboardingState::None;
100 // Isolated config has no key; pin readiness so the ready-tip path is exercised.
101 app.onboarding_needs_api_key = false;
102 let before = app.history.len();
103
104 app.maybe_show_feature_intro();
105 assert_eq!(app.history.len(), before, "intro must not hide empty state");
106 assert!(
107 app.status_message
108 .as_deref()
109 .is_some_and(|message| message.contains("Fleet") && message.contains("/fleet setup"))
110 );
111
112 // Persisted flag now set → a second call is a no-op.
113 assert!(
114 Settings::load()
115 .expect("settings should load")
116 .feature_intro_shown,
117 "feature_intro_shown should be persisted"
118 );
119 app.maybe_show_feature_intro();
120 assert_eq!(
121 app.history.len(),
122 before,
123 "intro must not repeat once the flag is persisted"
124 );
125
126 let _ = std::fs::remove_dir_all(&tmp);
127 }
128
129 #[test]
130 fn initial_input_prefill_waits_for_manual_submit() {
131 let mut options = test_options(false);
132 options.initial_input = Some(InitialInput::Prefill("review this PR".to_string()));
133
134 let app = App::new(options, &Config::default());
135
136 assert_eq!(app.input, "review this PR");
137 assert_eq!(app.cursor_position, "review this PR".chars().count());
138 assert!(!app.auto_submit_initial_input);
139 }
140
141 #[test]
142 fn initial_input_submit_marks_startup_dispatch() {
143 let mut options = test_options(false);
144 options.initial_input = Some(InitialInput::Submit(
145 "阅读项目 and wait for instructions".to_string(),
146 ));
147
148 let app = App::new(options, &Config::default());
149
150 assert_eq!(app.input, "阅读项目 and wait for instructions");
151 assert_eq!(
152 app.cursor_position,
153 "阅读项目 and wait for instructions".chars().count()
154 );
155 assert!(app.auto_submit_initial_input);
156 }
157
158 #[test]
159 fn composer_arrows_scroll_default_is_true_without_mouse_capture() {
160 assert!(default_composer_arrows_scroll_for_platform(false, false));
161 }
162
163 #[test]
164 fn composer_arrows_scroll_default_is_false_with_mouse_capture_on_non_windows() {
165 assert!(!default_composer_arrows_scroll_for_platform(true, false));
166 }
167
168 #[test]
169 fn composer_arrows_scroll_default_is_false_with_mouse_capture_on_windows() {
170 assert!(!default_composer_arrows_scroll_for_platform(true, true));
171 }
172
173 #[test]
174 fn composer_arrows_scroll_default_is_true_without_mouse_capture_on_windows() {
175 assert!(default_composer_arrows_scroll_for_platform(false, true));
176 }
177
178 #[test]
179 fn move_cursor_line_start_multiline() {
180 let mut app = App::new(test_options(false), &Config::default());
181 app.input = "abc\ndef\nghi".to_string();
182 app.cursor_position = "abc\ndef\nghi".chars().count(); // absolute end
183 app.move_cursor_line_start();
184 assert_eq!(app.cursor_position, "abc\ndef\n".len()); // start of "ghi"
185 }
186
187 #[test]
188 fn move_cursor_line_start_singleline() {
189 let mut app = App::new(test_options(false), &Config::default());
190 app.input = "hello".to_string();
191 app.cursor_position = 3;
192 app.move_cursor_line_start();
193 assert_eq!(app.cursor_position, 0);
194 }
195
196 #[test]
197 fn move_cursor_line_end_multiline() {
198 let mut app = App::new(test_options(false), &Config::default());
199 app.input = "abc\ndef\nghi".to_string();
200 app.cursor_position = 0; // start of first line
201 app.move_cursor_line_end();
202 assert_eq!(app.cursor_position, "abc".len()); // before first '\n'
203 }
204
205 #[test]
206 fn move_cursor_line_end_at_newline_stays_at_line_end() {
207 let mut app = App::new(test_options(false), &Config::default());
208 app.input = "abc\ndef\nghi".to_string();
209 app.cursor_position = "abc".len(); // on the '\n'
210 app.move_cursor_line_end();
211 assert_eq!(app.cursor_position, "abc".len()); // stays at line end
212 }
213
214 #[test]
215 fn move_cursor_line_end_last_line() {
216 let mut app = App::new(test_options(false), &Config::default());
217 app.input = "abc\ndef".to_string();
218 app.cursor_position = "abc\n".len(); // start of last line
219 app.move_cursor_line_end();
220 assert_eq!(app.cursor_position, "abc\ndef".chars().count()); // absolute end
221 }
222
223 #[test]
224 fn move_cursor_line_start_already_at_start() {
225 let mut app = App::new(test_options(false), &Config::default());
226 app.input = "abc\ndef".to_string();
227 app.cursor_position = "abc\n".len(); // start of second line
228 app.move_cursor_line_start();
229 assert_eq!(app.cursor_position, "abc\n".len()); // unchanged
230 }
231
232 #[test]
233 fn test_trust_mode_follows_yolo_on_startup() {
234 let _env_lock = lock_test_env();
235 let tmp = tempfile::tempdir().expect("tempdir");
236 let config_path = tmp.path().join("config.toml");
237 let _config_env = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
238 let mut options = test_options(true);
239 options.config_path = Some(config_path);
240 let app = App::new(options, &Config::default());
241 assert!(app.trust_mode);
242 }
243
244 #[test]
245 fn reasoning_effort_display_label_uses_codex_xhigh() {
246 assert_eq!(
247 ReasoningEffort::Off.display_label_for_provider(ApiProvider::OpenaiCodex),
248 "low"
249 );
250 assert_eq!(
251 ReasoningEffort::Medium.display_label_for_provider(ApiProvider::OpenaiCodex),
252 "medium"
253 );
254 assert_eq!(
255 ReasoningEffort::Max.display_label_for_provider(ApiProvider::OpenaiCodex),
256 "xhigh"
257 );
258 assert_eq!(
259 ReasoningEffort::Max.display_label_for_provider(ApiProvider::Deepseek),
260 "max"
261 );
262 assert_eq!(
263 ReasoningEffort::High.display_label_for_provider(ApiProvider::OpenaiCodex),
264 "high"
265 );
266
267 let mut app = App::new(test_options(false), &Config::default());
268 app.api_provider = ApiProvider::OpenaiCodex;
269 app.reasoning_effort = ReasoningEffort::Max;
270 app.auto_model = false;
271 assert_eq!(app.reasoning_effort_display_label(), "xhigh");
272
273 app.reasoning_effort = ReasoningEffort::Auto;
274 app.last_effective_reasoning_effort =
275 Some(EffectiveReasoningEffort::Tier(ReasoningEffort::Max));
276 assert_eq!(app.reasoning_effort_display_label(), "auto: xhigh");
277 }
278
279 #[test]
280 fn fixed_auto_reasoning_label_preserves_untiered_effective_receipt() {
281 let mut app = App::new(test_options(false), &Config::default());
282 app.api_provider = ApiProvider::Zai;
283 app.auto_model = false;
284 app.model = crate::config::ZAI_GLM_5_TURBO_MODEL.to_string();
285 app.active_route_base_url = crate::config::DEFAULT_ZAI_BASE_URL.to_string();
286 app.reasoning_effort = ReasoningEffort::Auto;
287 app.last_effective_reasoning_effort =
288 Some(EffectiveReasoningEffort::ThinkingEnabledGranularityUnavailable);
289
290 assert_eq!(
291 app.reasoning_effort_display_label(),
292 "auto→thinking enabled; granularity unavailable"
293 );
294 }
295
296 #[test]
297 fn cache_replay_keeps_untiered_reasoning_enabled() {
298 let mut app = App::new(test_options(false), &Config::default());
299 app.api_provider = ApiProvider::Zai;
300 app.auto_model = false;
301 app.model = crate::config::ZAI_GLM_5_TURBO_MODEL.to_string();
302 app.reasoning_effort = ReasoningEffort::Auto;
303 app.last_effective_reasoning_effort =
304 Some(EffectiveReasoningEffort::ThinkingEnabledGranularityUnavailable);
305
306 assert_eq!(
307 app.reasoning_effort_api_value_for_replay(
308 ApiProvider::Zai,
309 crate::config::DEFAULT_ZAI_BASE_URL,
310 crate::config::ZAI_GLM_5_TURBO_MODEL,
311 ),
312 Some("high")
313 );
314
315 app.api_provider = ApiProvider::Minimax;
316 app.model = crate::config::DEFAULT_MINIMAX_MODEL.to_string();
317 assert_eq!(
318 app.reasoning_effort_api_value_for_replay(
319 ApiProvider::Minimax,
320 crate::config::DEFAULT_MINIMAX_BASE_URL,
321 crate::config::DEFAULT_MINIMAX_MODEL,
322 ),
323 Some("high")
324 );
325
326 app.last_effective_reasoning_effort = Some(EffectiveReasoningEffort::Unavailable);
327 assert_eq!(
328 app.reasoning_effort_api_value_for_replay(
329 ApiProvider::Zai,
330 crate::config::DEFAULT_ZAI_BASE_URL,
331 crate::config::ZAI_GLM_5_TURBO_MODEL,
332 ),
333 None
334 );
335 }
336
337 #[test]
338 fn cache_replay_normalizes_reasoning_against_the_concrete_auto_route() {
339 let mut app = App::new(test_options(false), &Config::default());
340 app.api_provider = ApiProvider::Deepseek;
341 app.model = "auto".to_string();
342 app.auto_model = true;
343
344 app.reasoning_effort = ReasoningEffort::Off;
345 assert_eq!(
346 app.reasoning_effort_api_value_for_replay(
347 ApiProvider::OpenaiCodex,
348 crate::config::DEFAULT_OPENAI_CODEX_BASE_URL,
349 crate::config::DEFAULT_OPENAI_CODEX_MODEL,
350 ),
351 Some("low"),
352 "Codex must apply its Off-to-Low floor even when DeepSeek is configured"
353 );
354
355 app.reasoning_effort = ReasoningEffort::Medium;
356 assert_eq!(
357 app.reasoning_effort_api_value_for_replay(
358 ApiProvider::Moonshot,
359 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
360 crate::config::KIMI_CODE_K3_MODEL,
361 ),
362 Some("medium"),
363 "Kimi Code K3 must retain its exact-route Medium tier"
364 );
365 }
366
367 #[test]
368 fn cache_replay_target_uses_the_last_completed_auto_route() {
369 let mut app = App::new(test_options(false), &Config::default());
370 app.model = "auto".to_string();
371 app.auto_model = true;
372 app.last_effective_provider = Some(ApiProvider::OpenaiCodex);
373 app.last_effective_provider_identity = Some(ApiProvider::OpenaiCodex.as_str().to_string());
374 app.last_effective_model = Some(crate::config::DEFAULT_OPENAI_CODEX_MODEL.to_string());
375 app.session.last_base_url = Some(crate::config::DEFAULT_OPENAI_CODEX_BASE_URL.to_string());
376 app.push_turn_cache_record(TurnCacheRecord {
377 provider: Some(ApiProvider::OpenaiCodex),
378 provider_identity: Some(ApiProvider::OpenaiCodex.as_str().to_string()),
379 model: Some(crate::config::DEFAULT_OPENAI_CODEX_MODEL.to_string()),
380 auto_model: true,
381 input_tokens: 1,
382 output_tokens: 1,
383 cache_hit_tokens: None,
384 cache_miss_tokens: None,
385 cache_write_tokens: None,
386 reasoning_tokens: None,
387 cost_audit: None,
388 reasoning_replay_tokens: None,
389 recorded_at: std::time::Instant::now(),
390 });
391
392 let target = app
393 .cache_replay_target()
394 .expect("completed Auto route must be replayable");
395
396 assert_eq!(target.provider, ApiProvider::OpenaiCodex);
397 assert_eq!(target.provider_identity, ApiProvider::OpenaiCodex.as_str());
398 assert_eq!(
399 target.provider_id.as_deref(),
400 Some(ApiProvider::OpenaiCodex.as_str())
401 );
402 assert_eq!(target.model, crate::config::DEFAULT_OPENAI_CODEX_MODEL);
403 assert_eq!(
404 target.base_url.as_deref(),
405 Some(crate::config::DEFAULT_OPENAI_CODEX_BASE_URL)
406 );
407
408 // A restored Auto session has no turn ring or raw endpoint. Once warmup
409 // safely re-resolves that route, its exact key becomes sufficient
410 // endpoint evidence for a following inspect.
411 app.session.turn_cache_history.clear();
412 app.session.last_base_url = None;
413 app.session.last_warmup_key = Some(CacheWarmupKey {
414 provider: ApiProvider::OpenaiCodex.as_str().to_string(),
415 model: crate::config::DEFAULT_OPENAI_CODEX_MODEL.to_string(),
416 base_url: crate::config::DEFAULT_OPENAI_CODEX_BASE_URL.to_string(),
417 static_prefix_hash: "static".to_string(),
418 tool_catalog_hash: "tools".to_string(),
419 project_pack_hash: "project".to_string(),
420 skills_hash: "skills".to_string(),
421 });
422 assert_eq!(
423 app.cache_replay_target()
424 .and_then(|target| target.base_url)
425 .as_deref(),
426 Some(crate::config::DEFAULT_OPENAI_CODEX_BASE_URL)
427 );
428 }
429
430 #[test]
431 fn auto_reasoning_change_invalidates_the_previous_route_and_receipt() {
432 let mut app = App::new(test_options(false), &Config::default());
433 app.api_provider = ApiProvider::Deepseek;
434 app.model = "auto".to_string();
435 app.auto_model = true;
436 app.reasoning_effort = ReasoningEffort::Low;
437 app.reasoning_effort_preference = Some(ReasoningEffort::Low);
438 app.last_effective_provider = Some(ApiProvider::OpenaiCodex);
439 app.last_effective_provider_identity = Some(ApiProvider::OpenaiCodex.as_str().to_string());
440 app.last_effective_model = Some(crate::config::DEFAULT_OPENAI_CODEX_MODEL.to_string());
441 app.last_auto_route_receipt = Some(crate::model_routing::AutoRouteReceipt {
442 tier: crate::model_routing::AutoRouteTier::Strong,
443 pair: crate::model_routing::AutoRoutePair {
444 strong: crate::config::DEFAULT_OPENAI_CODEX_MODEL.to_string(),
445 fast: None,
446 },
447 scope: crate::model_routing::AutoRouteScope::ResolvedProvider,
448 data_path: crate::model_routing::AutoRouteDataPath::LocalHeuristic,
449 reason: crate::model_routing::AutoRouteReason::LocalHeuristic(
450 crate::model_routing::AutoRouteHeuristicReason::ComplexRequest,
451 ),
452 });
453 app.last_effective_reasoning_effort =
454 Some(EffectiveReasoningEffort::Tier(ReasoningEffort::Max));
455
456 assert!(
457 app.cache_replay_target().is_some(),
458 "the completed route is replayable before its classifier input changes"
459 );
460
461 app.cycle_effort();
462
463 assert_eq!(app.reasoning_effort, ReasoningEffort::Medium);
464 assert_eq!(
465 app.status_message.as_deref(),
466 Some("Reasoning effort: med"),
467 "the change must describe the new unresolved request, not the old Codex receipt"
468 );
469 assert_eq!(app.last_effective_reasoning_effort, None);
470 assert_eq!(app.last_effective_provider, None);
471 assert_eq!(app.last_effective_provider_identity, None);
472 assert_eq!(app.last_effective_model, None);
473 assert_eq!(app.last_auto_route_receipt, None);
474 assert!(
475 app.cache_replay_target().is_none(),
476 "cache replay must wait for a route accepted under the new reasoning request"
477 );
478
479 let work = app
480 .work_state_snapshot()
481 .expect("Work snapshot")
482 .expect("effort activity creates graph state");
483 let crate::work_graph::WorkActivityEvent::ReasoningEffortChanged { effective, .. } = work
484 .graph
485 .expect("Work Graph")
486 .activities
487 .last()
488 .cloned()
489 .expect("effort activity");
490 assert_eq!(
491 effective,
492 crate::work_graph::ReasoningEffortTier::Medium,
493 "the activity receipt must not reuse the previous turn's effective tier"
494 );
495 }
496
497 #[test]
498 fn mode_and_thinking_are_locked_while_a_turn_is_running() {
499 // #2982: while a turn is in flight, user-initiated mode/thinking changes
500 // are refused with a concise message instead of shifting the surface the
501 // engine is acting on.
502 let mut app = App::new(test_options(false), &Config::default());
503 app.mode = AppMode::Agent;
504 app.reasoning_effort = ReasoningEffort::Max;
505 app.is_loading = true;
506
507 app.cycle_mode();
508 assert_eq!(app.mode, AppMode::Agent, "mode must not change while busy");
509 assert!(
510 app.status_message
511 .as_deref()
512 .unwrap_or_default()
513 .contains("locked"),
514 "expected a 'locked' status message, got {:?}",
515 app.status_message
516 );
517
518 let before_effort = app.reasoning_effort;
519 app.cycle_effort();
520 assert_eq!(
521 app.reasoning_effort, before_effort,
522 "thinking must not change while busy"
523 );
524
525 // Once the turn finishes, the same gesture works again.
526 app.is_loading = false;
527 app.cycle_mode();
528 assert_ne!(app.mode, AppMode::Agent, "mode should change when idle");
529 }
530
531 #[test]
532 fn cycle_effort_updates_effort_status_and_compaction() {
533 // Ctrl+T parity with the hotbar's `reasoning.cycle` action: cycling the
534 // effort must surface a status message and refresh the compaction budget,
535 // not just silently flip the setting.
536 let mut app = App::new(test_options(false), &Config::default());
537 app.api_provider = ApiProvider::Deepseek;
538 app.auto_model = false;
539 app.reasoning_effort = ReasoningEffort::Off;
540 // Sentinel so the test can observe update_model_compaction_budget().
541 app.compact_threshold = 0;
542
543 app.cycle_effort();
544
545 assert_eq!(app.reasoning_effort, ReasoningEffort::High);
546 assert_eq!(app.reasoning_effort_preference, Some(ReasoningEffort::High));
547 assert_eq!(
548 app.status_message.as_deref(),
549 Some("Reasoning effort: high"),
550 "Ctrl+T must give visible feedback like the hotbar action"
551 );
552 assert_ne!(
553 app.compact_threshold, 0,
554 "cycling effort must refresh the compaction budget"
555 );
556 assert!(app.needs_redraw);
557
558 let work = app
559 .work_state_snapshot()
560 .expect("Work snapshot")
561 .expect("effort activity creates graph state");
562 let graph = work.graph.expect("Work Graph");
563 let activity = graph.activities.last().expect("effort activity");
564 match activity {
565 crate::work_graph::WorkActivityEvent::ReasoningEffortChanged {
566 requested,
567 effective,
568 provider_kind,
569 provider,
570 operation,
571 ..
572 } => {
573 assert_eq!(*requested, crate::work_graph::ReasoningEffortTier::High);
574 assert_eq!(*effective, crate::work_graph::ReasoningEffortTier::High);
575 assert_eq!(*provider_kind, Some(ApiProvider::Deepseek));
576 assert_eq!(provider, "deepseek");
577 assert!(operation.is_none());
578 }
579 }
580 let wire = serde_json::to_value(activity).expect("serialize activity");
581 assert_eq!(wire["kind"], "reasoning_effort_changed");
582 assert!(
583 wire.get("text").is_none(),
584 "activity must not carry reasoning text"
585 );
586 }
587
588 #[test]
589 fn glm_5_turbo_records_enabled_with_granularity_unavailable() {
590 let mut app = App::new(test_options(false), &Config::default());
591 app.api_provider = ApiProvider::Zai;
592 app.auto_model = false;
593 app.active_route_base_url = crate::config::DEFAULT_ZAI_BASE_URL.to_string();
594 app.model = crate::config::ZAI_GLM_5_TURBO_MODEL.to_string();
595 app.reasoning_effort = ReasoningEffort::High;
596
597 app.cycle_effort();
598
599 assert_eq!(app.reasoning_effort, ReasoningEffort::Max);
600 assert_eq!(
601 app.status_message.as_deref(),
602 Some("Reasoning effort: max→thinking enabled; granularity unavailable")
603 );
604 assert_eq!(
605 app.reasoning_effort_display_label(),
606 "max→thinking enabled; granularity unavailable"
607 );
608 let work = app
609 .work_state_snapshot()
610 .expect("Work snapshot")
611 .expect("effort activity creates graph state");
612 let activity = work
613 .graph
614 .expect("Work Graph")
615 .activities
616 .last()
617 .cloned()
618 .expect("effort activity");
619 let crate::work_graph::WorkActivityEvent::ReasoningEffortChanged {
620 requested,
621 effective,
622 provider,
623 ..
624 } = &activity;
625 assert_eq!(*requested, crate::work_graph::ReasoningEffortTier::Max);
626 assert_eq!(
627 *effective,
628 crate::work_graph::ReasoningEffortTier::ThinkingEnabledGranularityUnavailable
629 );
630 assert_eq!(provider, "zai");
631 assert_eq!(
632 serde_json::to_value(activity).expect("serialize activity")["effective"],
633 "thinking_enabled_granularity_unavailable"
634 );
635 }
636
637 #[test]
638 fn glm_5_1_records_enabled_with_granularity_unavailable() {
639 let mut app = App::new(test_options(false), &Config::default());
640 app.api_provider = ApiProvider::Zai;
641 app.auto_model = false;
642 app.active_route_base_url = crate::config::DEFAULT_ZAI_BASE_URL.to_string();
643 app.model = crate::config::ZAI_GLM_5_1_MODEL.to_string();
644 app.reasoning_effort = ReasoningEffort::High;
645
646 app.cycle_effort();
647
648 assert_eq!(
649 app.reasoning_effort_display_label(),
650 "max→thinking enabled; granularity unavailable"
651 );
652 let work = app
653 .work_state_snapshot()
654 .expect("Work snapshot")
655 .expect("effort activity creates graph state");
656 let crate::work_graph::WorkActivityEvent::ReasoningEffortChanged { effective, .. } = work
657 .graph
658 .expect("Work Graph")
659 .activities
660 .last()
661 .cloned()
662 .expect("effort activity");
663 assert_eq!(
664 effective,
665 crate::work_graph::ReasoningEffortTier::ThinkingEnabledGranularityUnavailable
666 );
667 }
668
669 #[test]
670 fn unknown_model_on_exact_zai_endpoint_records_effective_unavailable() {
671 let mut app = App::new(test_options(false), &Config::default());
672 app.api_provider = ApiProvider::Zai;
673 app.auto_model = false;
674 app.active_route_base_url = crate::config::DEFAULT_ZAI_BASE_URL.to_string();
675 app.model = "glm-future-unknown".to_string();
676 app.reasoning_effort = ReasoningEffort::High;
677
678 app.cycle_effort();
679
680 assert_eq!(
681 app.reasoning_effort_display_label(),
682 "max→effective unavailable"
683 );
684 let work = app
685 .work_state_snapshot()
686 .expect("Work snapshot")
687 .expect("effort activity creates graph state");
688 let crate::work_graph::WorkActivityEvent::ReasoningEffortChanged { effective, .. } = work
689 .graph
690 .expect("Work Graph")
691 .activities
692 .last()
693 .cloned()
694 .expect("effort activity");
695 assert_eq!(
696 effective,
697 crate::work_graph::ReasoningEffortTier::Unavailable
698 );
699 }
700
701 #[test]
702 fn compatible_zai_gateway_records_effective_unavailable() {
703 let mut app = App::new(test_options(false), &Config::default());
704 app.api_provider = ApiProvider::Zai;
705 app.auto_model = false;
706 app.active_route_base_url = "https://gateway.example/v1".to_string();
707 app.model = crate::config::ZAI_GLM_5_2_MODEL.to_string();
708 app.reasoning_effort = ReasoningEffort::High;
709
710 app.cycle_effort();
711
712 assert_eq!(app.reasoning_effort, ReasoningEffort::Max);
713 assert_eq!(
714 app.status_message.as_deref(),
715 Some("Reasoning effort: max→effective unavailable")
716 );
717 assert_eq!(
718 app.reasoning_effort_display_label(),
719 "max→effective unavailable"
720 );
721 let work = app
722 .work_state_snapshot()
723 .expect("Work snapshot")
724 .expect("effort activity creates graph state");
725 let activity = work
726 .graph
727 .expect("Work Graph")
728 .activities
729 .last()
730 .cloned()
731 .expect("effort activity");
732 let crate::work_graph::WorkActivityEvent::ReasoningEffortChanged {
733 requested,
734 effective,
735 provider,
736 ..
737 } = &activity;
738 assert_eq!(*requested, crate::work_graph::ReasoningEffortTier::Max);
739 assert_eq!(
740 *effective,
741 crate::work_graph::ReasoningEffortTier::Unavailable
742 );
743 assert_eq!(provider, "zai");
744 assert_eq!(
745 serde_json::to_value(activity).expect("serialize activity")["effective"],
746 "unavailable"
747 );
748 }
749
750 #[test]
751 fn minimax_m3_high_and_max_receipts_do_not_claim_tier_granularity() {
752 for (previous, requested, label) in [
753 (ReasoningEffort::Off, ReasoningEffort::High, "high"),
754 (ReasoningEffort::High, ReasoningEffort::Max, "max"),
755 ] {
756 let mut app = App::new(test_options(false), &Config::default());
757 app.api_provider = ApiProvider::Minimax;
758 app.auto_model = false;
759 app.active_route_base_url = crate::config::DEFAULT_MINIMAX_BASE_URL.to_string();
760 app.model = crate::config::DEFAULT_MINIMAX_MODEL.to_string();
761 app.reasoning_effort = previous;
762
763 app.cycle_effort();
764
765 assert_eq!(app.reasoning_effort, requested);
766 assert_eq!(
767 app.reasoning_effort_display_label(),
768 format!("{label}→thinking enabled; granularity unavailable")
769 );
770 let work = app
771 .work_state_snapshot()
772 .expect("Work snapshot")
773 .expect("effort activity creates graph state");
774 let activity = work
775 .graph
776 .expect("Work Graph")
777 .activities
778 .last()
779 .cloned()
780 .expect("effort activity");
781 let crate::work_graph::WorkActivityEvent::ReasoningEffortChanged {
782 effective,
783 endpoint_identity,
784 model,
785 ..
786 } = activity;
787 assert_eq!(
788 effective,
789 crate::work_graph::ReasoningEffortTier::ThinkingEnabledGranularityUnavailable
790 );
791 assert_eq!(
792 endpoint_identity.as_deref(),
793 Some(crate::config::DEFAULT_MINIMAX_BASE_URL)
794 );
795 assert_eq!(model.as_deref(), Some(crate::config::DEFAULT_MINIMAX_MODEL));
796 }
797 }
798
799 #[test]
800 fn minimax_anthropic_m3_high_and_max_receipts_match_adaptive_wire_truth() {
801 for (previous, requested, label) in [
802 (ReasoningEffort::Off, ReasoningEffort::High, "high"),
803 (ReasoningEffort::High, ReasoningEffort::Max, "max"),
804 ] {
805 let mut app = App::new(test_options(false), &Config::default());
806 app.api_provider = ApiProvider::MinimaxAnthropic;
807 app.auto_model = false;
808 app.active_route_base_url = crate::config::DEFAULT_MINIMAX_ANTHROPIC_BASE_URL.to_string();
809 app.model = crate::config::DEFAULT_MINIMAX_MODEL.to_string();
810 app.reasoning_effort = previous;
811
812 app.cycle_effort();
813
814 assert_eq!(app.reasoning_effort, requested);
815 assert_eq!(
816 app.reasoning_effort_display_label(),
817 format!("{label}→thinking enabled; granularity unavailable")
818 );
819 let work = app
820 .work_state_snapshot()
821 .expect("Work snapshot")
822 .expect("effort activity creates graph state");
823 let activity = work
824 .graph
825 .expect("Work Graph")
826 .activities
827 .last()
828 .cloned()
829 .expect("effort activity");
830 let crate::work_graph::WorkActivityEvent::ReasoningEffortChanged {
831 effective,
832 provider_kind,
833 provider,
834 endpoint_identity,
835 model,
836 ..
837 } = activity;
838 assert_eq!(
839 effective,
840 crate::work_graph::ReasoningEffortTier::ThinkingEnabledGranularityUnavailable
841 );
842 assert_eq!(provider_kind, Some(ApiProvider::MinimaxAnthropic));
843 assert_eq!(provider, "minimax-anthropic");
844 assert_eq!(
845 endpoint_identity.as_deref(),
846 Some(crate::config::DEFAULT_MINIMAX_ANTHROPIC_BASE_URL)
847 );
848 assert_eq!(model.as_deref(), Some(crate::config::DEFAULT_MINIMAX_MODEL));
849 }
850 }
851
852 #[test]
853 fn named_custom_route_displays_and_persists_effective_unavailable() {
854 let mut app = App::new(test_options(false), &Config::default());
855 app.set_provider_identity(ApiProvider::Custom, "my-gateway");
856 app.auto_model = false;
857 app.active_route_base_url = "https://gateway.example/v1?api_key=must-not-persist".to_string();
858 app.model = "vendor-model-x".to_string();
859 app.reasoning_effort = ReasoningEffort::High;
860
861 app.cycle_effort();
862
863 assert_eq!(app.reasoning_effort, ReasoningEffort::Max);
864 assert_eq!(
865 app.reasoning_effort_display_label(),
866 "max→effective unavailable"
867 );
868 let work = app
869 .work_state_snapshot()
870 .expect("Work snapshot")
871 .expect("unknown route activity creates valid graph state");
872 let activity = work
873 .graph
874 .expect("Work Graph")
875 .activities
876 .last()
877 .cloned()
878 .expect("effort activity");
879 let crate::work_graph::WorkActivityEvent::ReasoningEffortChanged {
880 effective,
881 provider_kind,
882 provider,
883 endpoint_identity,
884 model,
885 ..
886 } = activity;
887 assert_eq!(
888 effective,
889 crate::work_graph::ReasoningEffortTier::Unavailable
890 );
891 assert_eq!(provider_kind, Some(ApiProvider::Custom));
892 assert_eq!(provider, "my-gateway");
893 let endpoint = endpoint_identity.expect("redacted endpoint provenance");
894 assert!(endpoint.contains("gateway.example"), "{endpoint}");
895 assert!(!endpoint.contains("must-not-persist"), "{endpoint}");
896 assert_eq!(model.as_deref(), Some("vendor-model-x"));
897 }
898
899 #[test]
900 fn custom_routes_named_with_builtin_slugs_retain_custom_kind_and_fail_closed() {
901 for identity in ["openai", "zai"] {
902 let mut app = App::new(test_options(false), &Config::default());
903 app.set_provider_identity(ApiProvider::Custom, identity);
904 app.auto_model = false;
905 app.active_route_base_url = "https://gateway.example/v1".to_string();
906 app.model = "vendor-model-x".to_string();
907 app.reasoning_effort = ReasoningEffort::High;
908
909 app.cycle_effort();
910
911 assert_eq!(
912 app.reasoning_effort_display_label(),
913 "max→effective unavailable"
914 );
915 let work = app
916 .work_state_snapshot()
917 .expect("Work snapshot")
918 .expect("effort activity creates graph state");
919 let activity = work
920 .graph
921 .expect("Work Graph")
922 .activities
923 .last()
924 .cloned()
925 .expect("effort activity");
926 let crate::work_graph::WorkActivityEvent::ReasoningEffortChanged {
927 effective,
928 provider_kind,
929 provider,
930 ..
931 } = activity;
932 assert_eq!(
933 effective,
934 crate::work_graph::ReasoningEffortTier::Unavailable
935 );
936 assert_eq!(provider_kind, Some(ApiProvider::Custom));
937 assert_eq!(provider, identity);
938 }
939 }
940
941 #[test]
942 fn zai_gateway_off_and_high_receipts_remain_unavailable() {
943 for (previous, requested, label) in [
944 (ReasoningEffort::Max, ReasoningEffort::Off, "off"),
945 (ReasoningEffort::Off, ReasoningEffort::High, "high"),
946 ] {
947 let mut app = App::new(test_options(false), &Config::default());
948 app.api_provider = ApiProvider::Zai;
949 app.auto_model = false;
950 app.active_route_base_url = "https://gateway.example/v1".to_string();
951 app.model = crate::config::ZAI_GLM_5_2_MODEL.to_string();
952 app.reasoning_effort = previous;
953
954 app.cycle_effort();
955
956 assert_eq!(app.reasoning_effort, requested);
957 assert_eq!(
958 app.reasoning_effort_display_label(),
959 format!("{label}→effective unavailable")
960 );
961 }
962 }
963
964 #[test]
965 fn kimi_code_high_and_max_work_receipts_preserve_exact_tiers() {
966 for (previous, requested) in [
967 (ReasoningEffort::Off, ReasoningEffort::High),
968 (ReasoningEffort::High, ReasoningEffort::Max),
969 ] {
970 let mut app = App::new(test_options(false), &Config::default());
971 app.api_provider = ApiProvider::Moonshot;
972 app.auto_model = false;
973 app.active_route_base_url = crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string();
974 app.model = crate::config::KIMI_CODE_K3_MODEL.to_string();
975 app.reasoning_effort = previous;
976
977 app.cycle_effort();
978
979 let work = app
980 .work_state_snapshot()
981 .expect("Work snapshot")
982 .expect("effort activity creates graph state");
983 let activity = work
984 .graph
985 .expect("Work Graph")
986 .activities
987 .last()
988 .cloned()
989 .unwrap();
990 let crate::work_graph::WorkActivityEvent::ReasoningEffortChanged {
991 effective,
992 endpoint_identity,
993 model,
994 ..
995 } = activity;
996 assert_eq!(effective, requested.into());
997 assert_eq!(
998 endpoint_identity.as_deref(),
999 Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL)
1000 );
1001 assert_eq!(model.as_deref(), Some(crate::config::KIMI_CODE_K3_MODEL));
1002 }
1003 }
1004
1005 #[test]
1006 fn active_turn_zai_receipt_overrides_all_mutable_parallel_route_metadata() {
1007 let mut app = App::new(test_options(false), &Config::default());
1008 app.api_provider = ApiProvider::Deepseek;
1009 app.active_route_base_url = crate::config::DEFAULT_DEEPSEEK_BASE_URL.to_string();
1010 app.model = "deepseek-chat".to_string();
1011 app.reasoning_effort = ReasoningEffort::High;
1012 app.active_turn = Some(ActiveTurnMetadata {
1013 turn_id: "turn-zai-receipt".to_string(),
1014 created_at: chrono::Utc::now(),
1015 route: Some(crate::core::events::TurnRoute {
1016 provider: ApiProvider::Zai,
1017 provider_identity: "openai".to_string(),
1018 model: "mutable-wrong-model".to_string(),
1019 auto_model: false,
1020 receipt: Some(crate::route_receipt::TurnRouteReceipt::new(
1021 ApiProvider::Zai,
1022 "zai",
1023 crate::config::ZAI_GLM_5_TURBO_MODEL,
1024 crate::config::DEFAULT_ZAI_BASE_URL,
1025 "test-secret-never-persisted",
1026 )),
1027 billing: Some(crate::core::events::RouteBillingEnvelope {
1028 billing_surface: None,
1029 endpoint_fingerprint: None,
1030 billing_mode: crate::cost_status::RouteBillingMode::Unknown,
1031 dispatched_at: chrono::Utc::now(),
1032 }),
1033 base_url: crate::config::DEFAULT_ZAI_BASE_URL.to_string(),
1034 billing_product: crate::route_billing::RouteProduct::Unproven,
1035 }),
1036 auto_route_receipt: None,
1037 suggestion_authority: None,
1038 });
1039
1040 assert_eq!(
1041 app.reasoning_effort_display_label(),
1042 "high→thinking enabled; granularity unavailable"
1043 );
1044
1045 app.apply_reasoning_effort_cycle();
1046 let work = app
1047 .work_state_snapshot()
1048 .expect("Work snapshot")
1049 .expect("effort activity creates graph state");
1050 let activity = work
1051 .graph
1052 .expect("Work Graph")
1053 .activities
1054 .last()
1055 .cloned()
1056 .expect("effort activity");
1057 let crate::work_graph::WorkActivityEvent::ReasoningEffortChanged {
1058 provider_kind,
1059 provider,
1060 endpoint_identity,
1061 model,
1062 ..
1063 } = activity;
1064 assert_eq!(provider_kind, Some(ApiProvider::Zai));
1065 assert_eq!(provider, "zai");
1066 assert_eq!(
1067 endpoint_identity.as_deref(),
1068 Some(crate::config::DEFAULT_ZAI_BASE_URL)
1069 );
1070 assert_eq!(model.as_deref(), Some(crate::config::ZAI_GLM_5_TURBO_MODEL));
1071 }
1072
1073 #[test]
1074 fn pending_zai_route_without_endpoint_receipt_is_effective_unavailable() {
1075 let mut app = App::new(test_options(false), &Config::default());
1076 app.api_provider = ApiProvider::Deepseek;
1077 app.auto_model = false;
1078 app.reasoning_effort = ReasoningEffort::Max;
1079 app.pending_turn_route = Some((
1080 ApiProvider::Zai,
1081 crate::config::ZAI_GLM_5_2_MODEL.to_string(),
1082 true,
1083 ));
1084
1085 assert_eq!(
1086 app.reasoning_effort_display_label(),
1087 "max→effective unavailable"
1088 );
1089 }
1090
1091 #[test]
1092 fn reasoning_effort_display_receipts_route_normalization() {
1093 let mut app = App::new(test_options(false), &Config::default());
1094 app.api_provider = ApiProvider::Moonshot;
1095 app.auto_model = false;
1096 app.reasoning_effort = ReasoningEffort::Low;
1097 app.active_route_base_url = crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string();
1098 app.model = "kimi-k2.5".to_string();
1099
1100 assert_eq!(app.reasoning_effort_display_label(), "low→high");
1101
1102 app.active_route_base_url = crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string();
1103 app.model = "k3".to_string();
1104 assert_eq!(app.reasoning_effort_display_label(), "low");
1105
1106 app.reasoning_effort = ReasoningEffort::Off;
1107 assert_eq!(app.reasoning_effort_display_label(), "off→low");
1108 }
1109
1110 #[test]
1111 fn reasoning_effort_api_values_are_provider_aware_for_codex() {
1112 assert_eq!(
1113 ReasoningEffort::Off.normalize_for_provider(ApiProvider::OpenaiCodex),
1114 ReasoningEffort::Low
1115 );
1116 assert_eq!(
1117 ReasoningEffort::Auto.normalize_for_provider(ApiProvider::OpenaiCodex),
1118 ReasoningEffort::Medium
1119 );
1120 assert_eq!(
1121 ReasoningEffort::Max.api_value_for_provider(ApiProvider::OpenaiCodex),
1122 Some("xhigh")
1123 );
1124 assert_eq!(
1125 ReasoningEffort::Off.api_value_for_provider(ApiProvider::OpenaiCodex),
1126 Some("low")
1127 );
1128 assert_eq!(
1129 ReasoningEffort::Max.api_value_for_provider(ApiProvider::Deepseek),
1130 Some("max")
1131 );
1132 assert_eq!(
1133 ReasoningEffort::from_setting("ultracode"),
1134 ReasoningEffort::Max
1135 );
1136 }
1137
1138 #[test]
1139 fn reasoning_effort_uses_one_strict_alias_table_and_legacy_fallback() {
1140 for raw in ["off", "none", "disabled", "false"] {
1141 assert_eq!(ReasoningEffort::parse_strict(raw), Ok(ReasoningEffort::Off));
1142 }
1143 for raw in ["low", "minimum", "minimal", "light"] {
1144 assert_eq!(ReasoningEffort::parse_strict(raw), Ok(ReasoningEffort::Low));
1145 }
1146 for raw in ["medium", "mid"] {
1147 assert_eq!(
1148 ReasoningEffort::parse_strict(raw),
1149 Ok(ReasoningEffort::Medium)
1150 );
1151 }
1152 for raw in ["xhigh", "ultra", "max", "maximum", "ultracode"] {
1153 assert_eq!(ReasoningEffort::parse_strict(raw), Ok(ReasoningEffort::Max));
1154 }
1155 assert!(ReasoningEffort::parse_strict("surprise").is_err());
1156 assert_eq!(
1157 ReasoningEffort::from_setting("surprise"),
1158 ReasoningEffort::Max
1159 );
1160 }
1161
1162 #[test]
1163 fn reasoning_effort_normalizes_each_exact_k3_route_without_neighbor_leakage() {
1164 let kimi_base = crate::config::DEFAULT_KIMI_CODE_BASE_URL;
1165 let moonshot_base = crate::config::DEFAULT_MOONSHOT_BASE_URL;
1166 assert_eq!(
1167 ReasoningEffort::Off.normalize_for_route(ApiProvider::Moonshot, kimi_base, "k3"),
1168 ReasoningEffort::Low,
1169 "membership K3 stays on K3 by mapping off to its lowest thinking tier"
1170 );
1171 assert_eq!(
1172 ReasoningEffort::Auto.normalize_for_route(ApiProvider::Moonshot, kimi_base, "k3"),
1173 ReasoningEffort::Auto,
1174 "route normalization preserves the Auto sentinel until dispatch selects a concrete tier"
1175 );
1176 assert_eq!(
1177 ReasoningEffort::Low.normalize_for_route(ApiProvider::Moonshot, kimi_base, "k3"),
1178 ReasoningEffort::Low
1179 );
1180 assert_eq!(
1181 ReasoningEffort::Medium.normalize_for_route(ApiProvider::Moonshot, kimi_base, "k3"),
1182 ReasoningEffort::Medium
1183 );
1184 assert_eq!(
1185 ReasoningEffort::Low.normalize_for_route(ApiProvider::Moonshot, moonshot_base, "k3"),
1186 ReasoningEffort::High
1187 );
1188 assert_eq!(
1189 ReasoningEffort::Medium.normalize_for_route(
1190 ApiProvider::Moonshot,
1191 kimi_base,
1192 "kimi-for-coding",
1193 ),
1194 ReasoningEffort::High
1195 );
1196
1197 assert_eq!(
1198 ReasoningEffort::Off.normalize_for_route(
1199 ApiProvider::Moonshot,
1200 moonshot_base,
1201 crate::config::MOONSHOT_KIMI_K3_MODEL,
1202 ),
1203 ReasoningEffort::Low,
1204 "direct K3 is always-thinking, so off becomes its lowest supported tier"
1205 );
1206 assert_eq!(
1207 ReasoningEffort::Low.normalize_for_route(
1208 ApiProvider::Moonshot,
1209 moonshot_base,
1210 crate::config::MOONSHOT_KIMI_K3_MODEL,
1211 ),
1212 ReasoningEffort::Low
1213 );
1214 assert_eq!(
1215 ReasoningEffort::Medium.normalize_for_route(
1216 ApiProvider::Moonshot,
1217 moonshot_base,
1218 crate::config::MOONSHOT_KIMI_K3_MODEL,
1219 ),
1220 ReasoningEffort::High
1221 );
1222 assert_eq!(
1223 ReasoningEffort::Off.normalize_for_route(
1224 ApiProvider::Moonshot,
1225 "https://proxy.example/v1",
1226 crate::config::MOONSHOT_KIMI_K3_MODEL,
1227 ),
1228 ReasoningEffort::Off,
1229 "a neighboring gateway must not inherit direct-platform always-thinking semantics"
1230 );
1231 }
1232
1233 #[test]
1234 fn set_model_selection_normalizes_codex_fixed_model_effort() {
1235 let mut app = App::new(test_options(false), &Config::default());
1236 app.api_provider = ApiProvider::OpenaiCodex;
1237 app.reasoning_effort = ReasoningEffort::Off;
1238 app.reasoning_effort_preference = Some(ReasoningEffort::Off);
1239
1240 app.set_model_selection("gpt-5.5-codex".to_string());
1241
1242 assert_eq!(app.reasoning_effort, ReasoningEffort::Low);
1243 assert_eq!(app.reasoning_effort_preference, Some(ReasoningEffort::Off));
1244 assert!(!app.auto_model);
1245 assert_eq!(app.reasoning_effort_display_label(), "low");
1246 }
1247
1248 #[test]
1249 fn auto_model_selection_preserves_only_explicit_reasoning_effort() {
1250 let mut app = App::new(test_options(false), &Config::default());
1251 app.reasoning_effort = ReasoningEffort::Max;
1252 app.reasoning_effort_preference = None;
1253
1254 app.set_model_selection("auto".to_string());
1255
1256 assert!(app.auto_model);
1257 assert_eq!(app.reasoning_effort, ReasoningEffort::Auto);
1258 assert_eq!(app.reasoning_effort_preference, None);
1259
1260 for (provider, requested, normalized) in [
1261 (
1262 ApiProvider::Deepseek,
1263 ReasoningEffort::Low,
1264 ReasoningEffort::High,
1265 ),
1266 (
1267 ApiProvider::OpenaiCodex,
1268 ReasoningEffort::Off,
1269 ReasoningEffort::Low,
1270 ),
1271 ] {
1272 app.api_provider = provider;
1273 app.auto_model = false;
1274 app.model = "fixed-model".to_string();
1275 app.reasoning_effort = normalized;
1276 app.reasoning_effort_preference = Some(requested);
1277
1278 app.set_model_selection("auto".to_string());
1279
1280 assert_eq!(app.reasoning_effort, requested, "{provider:?}");
1281 assert_eq!(
1282 app.reasoning_effort_preference,
1283 Some(requested),
1284 "{provider:?}"
1285 );
1286 }
1287 }
1288
1289 #[test]
1290 fn app_new_normalizes_saved_codex_reasoning_effort() {
1291 let _lock = lock_test_env();
1292 let tmp = tempfile::TempDir::new().expect("tempdir");
1293 let config_path = tmp.path().join("config.toml");
1294 let _config_path = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
1295 let _token = EnvVarGuard::set("OPENAI_CODEX_ACCESS_TOKEN", "test-codex-startup-token");
1296 let config = Config {
1297 provider: Some("openai-codex".to_string()),
1298 providers: Some(ProvidersConfig {
1299 openai_codex: ProviderConfig {
1300 model: Some(crate::config::DEFAULT_OPENAI_CODEX_MODEL.to_string()),
1301 ..ProviderConfig::default()
1302 },
1303 ..ProvidersConfig::default()
1304 }),
1305 ..Config::default()
1306 };
1307
1308 for (raw, expected, display) in [
1309 ("off", ReasoningEffort::Low, "low"),
1310 ("auto", ReasoningEffort::Medium, "medium"),
1311 ("max", ReasoningEffort::Max, "xhigh"),
1312 ] {
1313 std::fs::write(
1314 tmp.path().join("settings.toml"),
1315 format!("reasoning_effort = \"{raw}\"\n"),
1316 )
1317 .expect("settings");
1318
1319 let app = App::new(test_options(false), &config);
1320
1321 assert_eq!(app.api_provider, ApiProvider::OpenaiCodex);
1322 assert_eq!(app.reasoning_effort, expected, "raw setting {raw}");
1323 assert_eq!(
1324 app.reasoning_effort_preference,
1325 Some(ReasoningEffort::from_setting(raw)),
1326 "raw setting {raw}"
1327 );
1328 assert_eq!(app.reasoning_effort_display_label(), display);
1329 }
1330 }
1331
1332 #[test]
1333 fn app_new_exposes_direct_moonshot_k3_off_as_effective_low() {
1334 let _lock = lock_test_env();
1335 let tmp = tempfile::TempDir::new().expect("tempdir");
1336 let config_path = tmp.path().join("config.toml");
1337 let _config_path = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
1338 std::fs::write(
1339 tmp.path().join("settings.toml"),
1340 "reasoning_effort = \"off\"\n",
1341 )
1342 .expect("settings");
1343 let config = Config {
1344 provider: Some("moonshot".to_string()),
1345 providers: Some(ProvidersConfig {
1346 moonshot: ProviderConfig {
1347 api_key: Some("moonshot-startup-test-key".to_string()),
1348 base_url: Some(crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string()),
1349 model: Some(crate::config::MOONSHOT_KIMI_K3_MODEL.to_string()),
1350 ..ProviderConfig::default()
1351 },
1352 ..ProvidersConfig::default()
1353 }),
1354 ..Config::default()
1355 };
1356
1357 let mut options = test_options(false);
1358 options.model = crate::config::MOONSHOT_KIMI_K3_MODEL.to_string();
1359 let app = App::new(options, &config);
1360
1361 assert_eq!(app.api_provider, ApiProvider::Moonshot);
1362 assert_eq!(app.model, crate::config::MOONSHOT_KIMI_K3_MODEL);
1363 assert_eq!(
1364 app.active_route_base_url,
1365 crate::config::DEFAULT_MOONSHOT_BASE_URL
1366 );
1367 assert_eq!(app.reasoning_effort, ReasoningEffort::Low);
1368 assert_eq!(app.reasoning_effort_display_label(), "low");
1369 }
1370
1371 #[test]
1372 fn codex_startup_threads_fresh_roster_context_into_active_route_limits() {
1373 let _lock = lock_test_env();
1374 let tmp = tempfile::tempdir().expect("tempdir");
1375 let config_path = tmp.path().join("config.toml");
1376 let codex_home = tmp.path().join("codex-home");
1377 std::fs::create_dir_all(&codex_home).expect("Codex home");
1378 std::fs::write(
1379 codex_home.join("models_cache.json"),
1380 serde_json::to_vec(&serde_json::json!({
1381 "fetched_at": chrono::Utc::now(),
1382 "models": [{
1383 "slug": crate::config::DEFAULT_OPENAI_CODEX_MODEL,
1384 "priority": 1,
1385 "context_window": 128000,
1386 "supported_reasoning_levels": [{"effort": "high"}]
1387 }]
1388 }))
1389 .expect("serialize cache"),
1390 )
1391 .expect("write cache");
1392 let _config_path = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
1393 let _codex_home = EnvVarGuard::set("CODEX_HOME", &codex_home);
1394 let _token = EnvVarGuard::set("OPENAI_CODEX_ACCESS_TOKEN", "test-codex-startup-token");
1395 let config = Config {
1396 provider: Some("openai-codex".to_string()),
1397 providers: Some(ProvidersConfig {
1398 openai_codex: ProviderConfig {
1399 model: Some(crate::config::DEFAULT_OPENAI_CODEX_MODEL.to_string()),
1400 ..ProviderConfig::default()
1401 },
1402 ..ProvidersConfig::default()
1403 }),
1404 ..Config::default()
1405 };
1406
1407 let mut options = test_options(false);
1408 options.model = crate::config::DEFAULT_OPENAI_CODEX_MODEL.to_string();
1409 let app = App::new(options, &config);
1410
1411 assert_eq!(app.api_provider, ApiProvider::OpenaiCodex);
1412 assert_eq!(
1413 app.active_route_limits
1414 .and_then(|limits| limits.context_tokens),
1415 Some(128_000)
1416 );
1417 assert_eq!(
1418 crate::route_budget::route_context_window_tokens(
1419 app.api_provider,
1420 &app.model,
1421 app.active_route_limits,
1422 ),
1423 128_000
1424 );
1425 }
1426
1427 #[test]
1428 fn settings_default_provider_auth_check_uses_provider_scoped_key() {
1429 let _lock = lock_test_env();
1430 let tmp = tempfile::TempDir::new().expect("tempdir");
1431 let config_path = tmp.path().join("config.toml");
1432 std::fs::write(
1433 tmp.path().join("settings.toml"),
1434 "default_provider = \"openai\"\n",
1435 )
1436 .expect("settings");
1437 let _config_path = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
1438 let _deepseek_key = EnvVarGuard::remove("DEEPSEEK_API_KEY");
1439 let _openai_key = EnvVarGuard::remove("OPENAI_API_KEY");
1440
1441 let config = Config {
1442 providers: Some(ProvidersConfig {
1443 openai: ProviderConfig {
1444 api_key: Some("openai-config-key".to_string()),
1445 ..ProviderConfig::default()
1446 },
1447 ..ProvidersConfig::default()
1448 }),
1449 ..Config::default()
1450 };
1451
1452 let app = App::new(test_options(false), &config);
1453
1454 assert_eq!(app.api_provider, ApiProvider::Openai);
1455 assert!(
1456 !app.onboarding_needs_api_key,
1457 "OpenAI provider config key should satisfy startup auth without a DeepSeek key"
1458 );
1459 assert!(!app.api_key_env_only);
1460 }
1461
1462 #[test]
1463 fn saved_startup_provider_overrides_config_file_provider() {
1464 let _lock = lock_test_env();
1465 let tmp = tempfile::TempDir::new().expect("tempdir");
1466 let config_path = tmp.path().join("config.toml");
1467 std::fs::write(
1468 tmp.path().join("settings.toml"),
1469 "default_provider = \"deepseek\"\ndefault_model = \"deepseek-v4-pro\"\n",
1470 )
1471 .expect("settings");
1472 let _config_path = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
1473
1474 let config = Config {
1475 provider: Some("xiaomi-mimo".to_string()),
1476 providers: Some(ProvidersConfig {
1477 deepseek: ProviderConfig {
1478 api_key: Some("deepseek-config-key".to_string()),
1479 model: Some("deepseek-v4-pro".to_string()),
1480 ..ProviderConfig::default()
1481 },
1482 xiaomi_mimo: ProviderConfig {
1483 api_key: Some("mimo-config-key".to_string()),
1484 model: Some("mimo-v2.5-pro".to_string()),
1485 ..ProviderConfig::default()
1486 },
1487 ..ProvidersConfig::default()
1488 }),
1489 ..Config::default()
1490 };
1491
1492 let mut options = test_options(false);
1493 options.model = "mimo-v2.5-pro".to_string();
1494 let app = App::new(options, &config);
1495
1496 assert_eq!(app.api_provider, ApiProvider::Deepseek);
1497 assert_eq!(app.model, "deepseek-v4-pro");
1498 assert!(
1499 !app.onboarding_needs_api_key,
1500 "the saved startup provider's config key should satisfy startup auth"
1501 );
1502 }
1503
1504 #[test]
1505 fn explicit_launch_provider_overrides_saved_startup_provider() {
1506 let _lock = lock_test_env();
1507 let tmp = tempfile::TempDir::new().expect("tempdir");
1508 let config_path = tmp.path().join("config.toml");
1509 std::fs::write(
1510 tmp.path().join("settings.toml"),
1511 "default_provider = \"deepseek\"\ndefault_model = \"deepseek-v4-pro\"\n",
1512 )
1513 .expect("settings");
1514 let _config_path = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
1515 let _provider = EnvVarGuard::set("CODEWHALE_PROVIDER", "xiaomi-mimo");
1516
1517 let config = Config {
1518 provider: Some("xiaomi-mimo".to_string()),
1519 providers: Some(ProvidersConfig {
1520 deepseek: ProviderConfig {
1521 api_key: Some("deepseek-config-key".to_string()),
1522 model: Some("deepseek-v4-pro".to_string()),
1523 ..ProviderConfig::default()
1524 },
1525 xiaomi_mimo: ProviderConfig {
1526 api_key: Some("mimo-config-key".to_string()),
1527 model: Some("mimo-v2.5-pro".to_string()),
1528 ..ProviderConfig::default()
1529 },
1530 ..ProvidersConfig::default()
1531 }),
1532 ..Config::default()
1533 };
1534
1535 let mut options = test_options(false);
1536 options.model = "mimo-v2.5-pro".to_string();
1537 let app = App::new(options, &config);
1538
1539 assert_eq!(app.api_provider, ApiProvider::XiaomiMimo);
1540 assert_eq!(app.model, "mimo-v2.5-pro");
1541 }
1542
1543 #[test]
1544 fn app_new_defaults_auto_compact_on_for_256k_class_models_when_unset() {
1545 let _lock = lock_test_env();
1546 let tmp = tempfile::TempDir::new().expect("tempdir");
1547 let config_path = tmp.path().join("config.toml");
1548 let _config_path = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
1549
1550 let mut options = test_options(false);
1551 options.model = "trinity-large-thinking".to_string();
1552 let app = App::new(options, &Config::default());
1553
1554 assert!(app.auto_compact);
1555 assert!(!app.auto_compact_user_configured);
1556 assert_eq!(app.auto_compact_threshold_percent, 80.0);
1557 assert_eq!(app.compact_threshold, 156_467);
1558 }
1559
1560 #[test]
1561 fn app_new_defaults_auto_compact_on_for_v4_class_models_when_unset() {
1562 let _lock = lock_test_env();
1563 let tmp = tempfile::TempDir::new().expect("tempdir");
1564 let config_path = tmp.path().join("config.toml");
1565 let _config_path = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
1566
1567 let mut options = test_options(false);
1568 options.model = "deepseek-v4-pro".to_string();
1569 let app = App::new(options, &Config::default());
1570
1571 assert!(app.auto_compact);
1572 assert!(!app.auto_compact_user_configured);
1573 assert_eq!(app.auto_compact_threshold_percent, 80.0);
1574 assert_eq!(app.compact_threshold, 589_466);
1575 }
1576
1577 #[test]
1578 fn app_new_respects_explicit_auto_compact_false_for_256k_class_models() {
1579 let _lock = lock_test_env();
1580 let tmp = tempfile::TempDir::new().expect("tempdir");
1581 let config_path = tmp.path().join("config.toml");
1582 std::fs::write(tmp.path().join("settings.toml"), "auto_compact = false\n").expect("settings");
1583 let _config_path = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
1584
1585 let mut options = test_options(false);
1586 options.model = "trinity-large-thinking".to_string();
1587 let app = App::new(options, &Config::default());
1588
1589 assert!(!app.auto_compact);
1590 assert!(app.auto_compact_user_configured);
1591 assert_eq!(app.compact_threshold, 156_467);
1592 }
1593
1594 #[test]
1595 fn app_new_respects_explicit_auto_compact_false_for_v4_class_models() {
1596 let _lock = lock_test_env();
1597 let tmp = tempfile::TempDir::new().expect("tempdir");
1598 let config_path = tmp.path().join("config.toml");
1599 std::fs::write(tmp.path().join("settings.toml"), "auto_compact = false\n").expect("settings");
1600 let _config_path = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
1601
1602 let mut options = test_options(false);
1603 options.model = "deepseek-v4-pro".to_string();
1604 let app = App::new(options, &Config::default());
1605
1606 assert!(!app.auto_compact);
1607 assert!(app.auto_compact_user_configured);
1608 assert_eq!(app.compact_threshold, 589_466);
1609 }
1610
1611 #[test]
1612 fn cny_display_falls_back_to_usd_for_usd_only_costs() {
1613 let mut app = App::new(test_options(false), &Config::default());
1614 app.cost_currency = CostCurrency::Cny;
1615 app.accrue_session_cost_estimate(CostEstimate::usd_only(0.42));
1616 app.session.cost_priced_turns = 1;
1617
1618 let displayed = app.displayed_session_cost_for_currency(CostCurrency::Cny);
1619
1620 assert_eq!(displayed, 0.42);
1621 assert_eq!(app.session_cost_for_currency(CostCurrency::Cny), 0.42);
1622 assert_eq!(app.format_cost_amount(displayed), "$0.42");
1623 }
1624
1625 #[test]
1626 fn cny_display_keeps_cny_when_costs_have_cny_rates() {
1627 let mut app = App::new(test_options(false), &Config::default());
1628 app.cost_currency = CostCurrency::Cny;
1629 app.accrue_session_cost_estimate(CostEstimate {
1630 usd: 0.42,
1631 cny: 2.5,
1632 });
1633 app.session.cost_priced_turns = 1;
1634 app.session.cost_cny_priced_turns = 1;
1635
1636 let displayed = app.displayed_session_cost_for_currency(CostCurrency::Cny);
1637
1638 assert_eq!(displayed, 2.5);
1639 assert_eq!(app.format_cost_amount(displayed), "¥2.50");
1640 }
1641
1642 #[test]
1643 fn cny_display_does_not_fall_back_to_an_unproven_usd_total() {
1644 let mut app = App::new(test_options(false), &Config::default());
1645 app.cost_currency = CostCurrency::Cny;
1646 app.accrue_session_cost_estimate(CostEstimate::usd_only(0.42));
1647
1648 assert_eq!(
1649 app.cost_display_currency(CostCurrency::Cny),
1650 CostCurrency::Cny
1651 );
1652 assert_eq!(
1653 app.displayed_session_cost_for_currency(CostCurrency::Cny),
1654 0.0
1655 );
1656 }
1657
1658 #[test]
1659 fn subscription_route_hides_stale_session_dollars_in_footer() {
1660 let mut app = App::new(test_options(false), &Config::default());
1661 app.accrue_session_cost_estimate(CostEstimate::usd_only(12.34));
1662 app.billing_presentation =
1663 crate::route_billing::BillingPresentation::Subscription("Codex OAuth quota");
1664 // Stale unaudited dollars must never render on a plan route; the usage
1665 // chip carries the plan-aware line instead of money or silence.
1666 let chip = app.cumulative_usage_chip();
1667 assert!(
1668 !matches!(chip, crate::route_billing::UsageChip::Money(_)),
1669 "{chip:?}"
1670 );
1671 let rendered = crate::route_billing::format_usage_chip(&chip).unwrap_or_default();
1672 assert!(!rendered.contains('$'), "{rendered}");
1673 assert!(rendered.contains("Codex OAuth quota"), "{rendered}");
1674 }
1675
1676 #[test]
1677 fn provider_switch_keeps_audited_cumulative_spend_visible() {
1678 let mut app = App::new(test_options(false), &Config::default());
1679 let usage = crate::models::Usage {
1680 input_tokens: 10_000,
1681 output_tokens: 1_000,
1682 ..Default::default()
1683 };
1684 let priced = crate::pricing::audit_turn_cost_for_provider_at(
1685 ApiProvider::Deepseek,
1686 "deepseek-v4-flash",
1687 &usage,
1688 chrono::Utc::now(),
1689 );
1690 app.record_turn_cost_audit(&priced);
1691 app.accrue_session_cost_estimate(priced.estimate.expect("priced"));
1692
1693 app.api_provider = ApiProvider::OpenaiCodex;
1694 app.model = "gpt-5.5".to_string();
1695 app.billing_presentation =
1696 crate::route_billing::BillingPresentation::Subscription("Codex OAuth quota");
1697 assert!(matches!(
1698 app.cumulative_usage_chip(),
1699 crate::route_billing::UsageChip::Money(_)
1700 ));
1701 assert!(
1702 crate::route_billing::format_usage_chip(&app.cumulative_usage_chip())
1703 .is_some_and(|label| !label.is_empty())
1704 );
1705
1706 let unknown = crate::pricing::audit_turn_cost_for_route_at(
1707 ApiProvider::Openai,
1708 "gpt-5.5",
1709 Some(crate::pricing::UNCLASSIFIED_BILLING_SURFACE),
1710 &usage,
1711 chrono::Utc::now(),
1712 );
1713 app.record_turn_cost_audit(&unknown);
1714 assert!(matches!(
1715 app.cumulative_usage_chip(),
1716 crate::route_billing::UsageChip::PricedSubtotal { legacy: false, .. }
1717 ));
1718 }
1719
1720 #[test]
1721 fn slash_command_classifier_treats_absolute_path_as_message() {
1722 assert!(looks_like_slash_command_input("/"));
1723 assert!(looks_like_slash_command_input("/help"));
1724 assert!(looks_like_slash_command_input("/model deepseek-v4-pro"));
1725 assert!(!looks_like_slash_command_input("/ hello"));
1726 assert!(!looks_like_slash_command_input(" / hello"));
1727 assert!(!looks_like_slash_command_input(
1728 "/usr/lib/x86_64-linux-gnu/ 是标准路径吗?"
1729 ));
1730 }
1731
1732 #[test]
1733 fn bang_shell_prefix_parses_compact_and_spaced_forms() {
1734 assert_eq!(shell_command_from_bang_input("!pwd"), Ok(Some("pwd")));
1735 assert_eq!(shell_command_from_bang_input("! pwd"), Ok(Some("pwd")));
1736 assert_eq!(
1737 shell_command_from_bang_input(" ! cargo test -p codewhale-tui sidebar"),
1738 Ok(Some("cargo test -p codewhale-tui sidebar"))
1739 );
1740 assert_eq!(shell_command_from_bang_input("normal message"), Ok(None));
1741 }
1742
1743 #[test]
1744 fn bang_shell_prefix_rejects_empty_command() {
1745 assert_eq!(
1746 shell_command_from_bang_input("!"),
1747 Err("Usage: ! <shell command>")
1748 );
1749 assert_eq!(
1750 shell_command_from_bang_input("! "),
1751 Err("Usage: ! <shell command>")
1752 );
1753 }
1754
1755 #[test]
1756 fn stop_word_matching_requires_one_token() {
1757 let words = vec!["stop".to_string(), "wait".to_string(), "pause".to_string()];
1758 assert_eq!(is_stop_word("STOP", &words).as_deref(), Some("stop"));
1759 assert_eq!(is_stop_word("+ stop", &words).as_deref(), Some("stop"));
1760 assert_eq!(is_stop_word("!wait", &words).as_deref(), Some("wait"));
1761 assert_eq!(is_stop_word("pause.", &words).as_deref(), Some("pause"));
1762 assert!(is_stop_word("please stop", &words).is_none());
1763 assert!(is_stop_word("don't stop", &words).is_none());
1764 }
1765
1766 #[test]
1767 fn submit_input_records_absolute_slash_path_as_message_history() {
1768 let mut app = App::new(test_options(false), &Config::default());
1769 let input = "/usr/lib/x86_64-linux-gnu/ 是标准路径吗?";
1770 app.input = input.to_string();
1771 app.cursor_position = input.chars().count();
1772
1773 let submitted = app.submit_input().expect("expected submitted input");
1774
1775 assert_eq!(submitted, input);
1776 assert_eq!(app.input_history.last().map(String::as_str), Some(input));
1777 }
1778
1779 #[test]
1780 fn restore_last_submitted_prompt_rehydrates_empty_composer() {
1781 let mut app = App::new(test_options(false), &Config::default());
1782 app.last_submitted_prompt = Some("fix the typo\nand retry".to_string());
1783
1784 assert!(app.restore_last_submitted_prompt_if_empty());
1785
1786 assert_eq!(app.input, "fix the typo\nand retry");
1787 assert_eq!(app.cursor_position, app.input.chars().count());
1788 assert!(app.needs_redraw);
1789 }
1790
1791 #[test]
1792 fn restore_last_submitted_prompt_preserves_existing_draft() {
1793 let mut app = App::new(test_options(false), &Config::default());
1794 app.last_submitted_prompt = Some("previous prompt".to_string());
1795 app.input = "new draft".to_string();
1796 app.cursor_position = app.input.chars().count();
1797
1798 assert!(!app.restore_last_submitted_prompt_if_empty());
1799
1800 assert_eq!(app.input, "new draft");
1801 assert_eq!(app.cursor_position, "new draft".chars().count());
1802 }
1803
1804 #[test]
1805 fn composer_strips_raw_sgr_mouse_report_when_mouse_capture_is_enabled() {
1806 let mut app = App::new(test_options(false), &Config::default());
1807 app.use_mouse_capture = true;
1808
1809 app.insert_str("[<35;44;18M");
1810
1811 assert_eq!(app.input, "");
1812 assert_eq!(app.cursor_position, 0);
1813 }
1814
1815 #[test]
1816 fn composer_strips_corrupted_mouse_report_burst() {
1817 let mut app = App::new(test_options(false), &Config::default());
1818 app.use_mouse_capture = true;
1819 app.insert_str("draft ");
1820 let leaked = "43;19M[<35;44;18M[<35;45;18M5;46;18M;48;18M";
1821
1822 app.insert_str(leaked);
1823
1824 assert_eq!(app.input, "draft ");
1825 assert_eq!(app.cursor_position, "draft ".chars().count());
1826 }
1827
1828 #[test]
1829 fn composer_preserves_draft_suffix_when_stripping_mouse_report() {
1830 let mut app = App::new(test_options(false), &Config::default());
1831 app.use_mouse_capture = true;
1832 app.insert_str("commit -m");
1833
1834 app.insert_str("[<65;44;18M");
1835
1836 assert_eq!(app.input, "commit -m");
1837 assert_eq!(app.cursor_position, "commit -m".chars().count());
1838 }
1839
1840 #[test]
1841 fn composer_preserves_numeric_draft_when_stripping_mouse_report() {
1842 let mut app = App::new(test_options(false), &Config::default());
1843 app.use_mouse_capture = true;
1844 app.insert_str("123");
1845
1846 app.insert_str("[<65;44;18M");
1847
1848 assert_eq!(app.input, "123");
1849 assert_eq!(app.cursor_position, 3);
1850 }
1851
1852 #[test]
1853 fn composer_strips_raw_sgr_mouse_report_when_mouse_capture_is_disabled() {
1854 let mut app = App::new(test_options(false), &Config::default());
1855
1856 app.insert_str("[<35;44;18M");
1857
1858 assert_eq!(app.input, "");
1859 assert_eq!(app.cursor_position, 0);
1860 }
1861
1862 #[test]
1863 fn composer_strips_tail_only_mouse_report_burst_when_mouse_capture_is_disabled() {
1864 let mut app = App::new(test_options(false), &Config::default());
1865 app.insert_str("draft ");
1866
1867 app.insert_str(";76;20M35;74;22M35;73;23M");
1868
1869 assert_eq!(app.input, "draft ");
1870 assert_eq!(app.cursor_position, "draft ".chars().count());
1871 }
1872
1873 #[test]
1874 fn composer_keeps_coordinate_like_text_when_mouse_capture_is_disabled() {
1875 let mut app = App::new(test_options(false), &Config::default());
1876
1877 app.insert_str("Size 12;34M");
1878
1879 assert_eq!(app.input, "Size 12;34M");
1880 assert_eq!(app.cursor_position, "Size 12;34M".chars().count());
1881 }
1882
1883 #[test]
1884 fn composer_keeps_normal_bracket_text_with_mouse_capture_enabled() {
1885 let mut app = App::new(test_options(false), &Config::default());
1886 app.use_mouse_capture = true;
1887
1888 app.insert_str("Use [<tag>] normally");
1889
1890 assert_eq!(app.input, "Use [<tag>] normally");
1891 }
1892
1893 #[test]
1894 fn composer_keeps_coordinate_like_text_with_mouse_capture_enabled() {
1895 let mut app = App::new(test_options(false), &Config::default());
1896 app.use_mouse_capture = true;
1897
1898 app.insert_str("Size 12;34M");
1899
1900 assert_eq!(app.input, "Size 12;34M");
1901 }
1902
1903 // === Bug #1915: broader terminal control-sequence fragments leaking
1904 // into the composer during dense streaming output. The narrow SGR
1905 // mouse-report filter installed in e63a4ba4a covers `[<…M` style
1906 // bursts, but not OSC 8 hyperlink fragments (`]8;;http…`) or Kitty
1907 // keyboard protocol responses (`[?u`, `[>1u`). These can arrive when
1908 // crossterm's event reader is mid-sequence and the unparsed tail is
1909 // delivered as individual Char(c) keystrokes that land in the input.
1910
1911 #[test]
1912 fn composer_strips_osc8_hyperlink_fragment() {
1913 let mut app = App::new(test_options(false), &Config::default());
1914 app.use_mouse_capture = true;
1915 app.insert_str("draft ");
1916
1917 // OSC 8 prefix with URL body but no terminator delivered yet —
1918 // exactly what crossterm hands us if its event reader is
1919 // interrupted mid-sequence and the leading ESC is consumed by the
1920 // parser before the rest gets reclassified as Char(c).
1921 app.insert_str("]8;;https://example.com");
1922
1923 assert_eq!(app.input, "draft ");
1924 assert_eq!(app.cursor_position, "draft ".chars().count());
1925 }
1926
1927 #[test]
1928 fn composer_strips_closing_osc8_fragment() {
1929 let mut app = App::new(test_options(false), &Config::default());
1930 app.use_mouse_capture = true;
1931 app.insert_str("hello ");
1932
1933 // The closing wrapper `]8;;` (with a stray ST `\\` from a
1934 // chopped escape) can arrive on its own when the parser ate
1935 // the start of the sequence in a previous read but caught the
1936 // tail as keystrokes.
1937 app.insert_str("]8;;\\");
1938
1939 assert_eq!(app.input, "hello ");
1940 assert_eq!(app.cursor_position, "hello ".chars().count());
1941 }
1942
1943 #[test]
1944 fn composer_strips_kitty_keyboard_protocol_fragment() {
1945 let mut app = App::new(test_options(false), &Config::default());
1946 app.use_mouse_capture = true;
1947 app.insert_str("ready ");
1948
1949 // Kitty keyboard protocol responses look like `\x1b[?1u`,
1950 // `\x1b[>1u`, `\x1b[<1u`, or `\x1b[?u`. With the ESC consumed,
1951 // the tail shape is `[?…u`, `[>…u`, or `[<…u`.
1952 app.insert_str("[?1u[>1u[<1u[?u");
1953
1954 assert_eq!(app.input, "ready ");
1955 assert_eq!(app.cursor_position, "ready ".chars().count());
1956 }
1957
1958 #[test]
1959 fn composer_strips_dec_private_mode_set_reset_fragments() {
1960 let mut app = App::new(test_options(false), &Config::default());
1961 app.use_mouse_capture = true;
1962 app.insert_str("ok ");
1963
1964 // Regression for #2592: DEC private mode set/reset chatter ends in
1965 // `h`/`l`, not `u`, so the `u`-only terminator used to leak the
1966 // leading `[`. Bracketed paste, mouse capture, focus reporting, and
1967 // synchronized output all leak during dense streaming.
1968 app.insert_str("[?2004h[?2004l[?1000h[?1004h[?2026h[?25l");
1969
1970 assert_eq!(app.input, "ok ");
1971 assert_eq!(app.cursor_position, "ok ".chars().count());
1972 }
1973
1974 #[test]
1975 fn composer_keeps_bracket_question_word_text() {
1976 let mut app = App::new(test_options(false), &Config::default());
1977 app.use_mouse_capture = true;
1978
1979 // The `h`/`l` terminator only counts after a numeric parameter, so
1980 // ordinary prose where a letter follows `[?` directly is preserved.
1981 app.insert_str("[?help] and [?later]");
1982
1983 assert_eq!(app.input, "[?help] and [?later]");
1984 }
1985
1986 #[test]
1987 fn composer_strips_mixed_control_sequence_burst() {
1988 let mut app = App::new(test_options(false), &Config::default());
1989 app.use_mouse_capture = true;
1990 app.insert_str("hi");
1991
1992 // Mixed dense burst combining all three fragment families
1993 // described in #1915.
1994 app.insert_str("[<35;44;18M]8;;https://example.com[?1u");
1995
1996 assert_eq!(app.input, "hi");
1997 assert_eq!(app.cursor_position, 2);
1998 }
1999
2000 #[test]
2001 fn composer_keeps_legitimate_url_text_with_mouse_capture_enabled() {
2002 let mut app = App::new(test_options(false), &Config::default());
2003 app.use_mouse_capture = true;
2004
2005 // URLs typed by the user must survive the filter — only
2006 // recognized control-sequence shapes are stripped.
2007 app.insert_str("see https://example.com/path?a=1&b=2 for info");
2008
2009 assert_eq!(app.input, "see https://example.com/path?a=1&b=2 for info");
2010 }
2011
2012 #[test]
2013 fn composer_keeps_legitimate_bracket_question_text() {
2014 let mut app = App::new(test_options(false), &Config::default());
2015 app.use_mouse_capture = true;
2016
2017 // Text that uses brackets, question marks, and lowercase `u` —
2018 // shapes that overlap Kitty fragments — must not be eaten.
2019 app.insert_str("[is this ok?] sure");
2020
2021 assert_eq!(app.input, "[is this ok?] sure");
2022 }
2023
2024 #[test]
2025 fn composer_keeps_legitimate_closing_bracket_digit_text() {
2026 let mut app = App::new(test_options(false), &Config::default());
2027 app.use_mouse_capture = true;
2028
2029 // Plain `]8` followed by spaces and words must survive — only
2030 // the OSC 8 shape `]8;` (with the mandatory `;` separator)
2031 // should be treated as a fragment.
2032 app.insert_str("array[]8 elements");
2033
2034 assert_eq!(app.input, "array[]8 elements");
2035 }
2036
2037 // initial_onboarding_state tests
2038 // These pin the logic that decides whether the TUI shows the
2039 // onboarding flow (Welcome → Language → Provider setup → …) or goes
2040 // straight to the chat view. Getting this wrong either locks
2041 // first-run users out of the API-key prompt or nags returning
2042 // users whose key is already configured.
2043
2044 #[test]
2045 fn skip_onboarding_suppresses_all_onboarding_states() {
2046 assert_eq!(
2047 initial_onboarding_state(true, false, true, true),
2048 OnboardingState::None
2049 );
2050 assert_eq!(
2051 initial_onboarding_state(true, true, true, true),
2052 OnboardingState::None
2053 );
2054 }
2055
2056 #[test]
2057 fn fully_configured_returning_user_skips_onboarding() {
2058 assert_eq!(
2059 initial_onboarding_state(false, true, false, false),
2060 OnboardingState::None
2061 );
2062 }
2063
2064 #[test]
2065 fn returning_user_missing_api_key_goes_to_canonical_provider_setup() {
2066 assert_eq!(
2067 initial_onboarding_state(false, true, true, false),
2068 OnboardingState::Provider
2069 );
2070 // workspace trust doesn't affect the api-key gate
2071 assert_eq!(
2072 initial_onboarding_state(false, true, true, true),
2073 OnboardingState::Provider
2074 );
2075 }
2076
2077 #[test]
2078 fn first_run_user_always_starts_at_welcome() {
2079 assert_eq!(
2080 initial_onboarding_state(false, false, false, false),
2081 OnboardingState::Welcome
2082 );
2083 assert_eq!(
2084 initial_onboarding_state(false, false, true, false),
2085 OnboardingState::Welcome
2086 );
2087 assert_eq!(
2088 initial_onboarding_state(false, false, false, true),
2089 OnboardingState::Welcome
2090 );
2091 }
2092
2093 #[test]
2094 fn onboarding_workspace_trust_gate_only_fires_for_onboarded_user() {
2095 assert!(onboarding_is_workspace_trust_gate(false, true, false, true));
2096 assert!(!onboarding_is_workspace_trust_gate(true, true, false, true));
2097 assert!(!onboarding_is_workspace_trust_gate(false, true, true, true));
2098 assert!(!onboarding_is_workspace_trust_gate(
2099 false, false, false, true
2100 ));
2101 }
2102
2103 #[test]
2104 fn onboarded_user_still_gets_workspace_trust_prompt_when_needed() {
2105 assert_eq!(
2106 initial_onboarding_state(false, true, false, true),
2107 OnboardingState::TrustDirectory
2108 );
2109 }
2110
2111 // App::new tests: missing key is detected
2112
2113 #[test]
2114 fn app_new_detects_missing_api_key_with_default_config() {
2115 let _lock = lock_test_env();
2116 let tmp = tempfile::TempDir::new().expect("tempdir");
2117 let config_path = tmp.path().join("config.toml");
2118 let _config_path = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
2119 let _provider_env = EnvVarGuard::remove("CODEWHALE_PROVIDER");
2120 let _legacy_provider_env = EnvVarGuard::remove("DEEPSEEK_PROVIDER");
2121 let _api_key_envs: Vec<_> = [
2122 "DEEPSEEK_API_KEY",
2123 "NVIDIA_API_KEY",
2124 "NVIDIA_NIM_API_KEY",
2125 "OPENAI_API_KEY",
2126 "ATLASCLOUD_API_KEY",
2127 "WANJIE_ARK_API_KEY",
2128 "WANJIE_API_KEY",
2129 "WANJIE_MAAS_API_KEY",
2130 "OPENROUTER_API_KEY",
2131 "NOVITA_API_KEY",
2132 "FIREWORKS_API_KEY",
2133 "SILICONFLOW_API_KEY",
2134 "MOONSHOT_API_KEY",
2135 "KIMI_API_KEY",
2136 "SGLANG_API_KEY",
2137 "VLLM_API_KEY",
2138 "OLLAMA_API_KEY",
2139 ]
2140 .into_iter()
2141 .map(EnvVarGuard::remove)
2142 .collect();
2143
2144 // Config::default() carries no api_key, and this test isolates process
2145 // env/settings so previous tests or developer shells cannot satisfy it.
2146 let app = App::new(test_options(false), &Config::default());
2147 assert!(
2148 app.onboarding_needs_api_key,
2149 "default config (no key) must set onboarding_needs_api_key"
2150 );
2151 }
2152
2153 #[test]
2154 fn app_new_with_explicit_api_key_does_not_trigger_onboarding() {
2155 let _lock = lock_test_env();
2156 let tmp = tempfile::TempDir::new().expect("tempdir");
2157 let config_path = tmp.path().join("config.toml");
2158 let _config_path = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
2159 let _provider_env = EnvVarGuard::remove("CODEWHALE_PROVIDER");
2160 let _legacy_provider_env = EnvVarGuard::remove("DEEPSEEK_PROVIDER");
2161
2162 let config = Config {
2163 api_key: Some("sk-test-onboarding-key".to_string()),
2164 ..Config::default()
2165 };
2166 let app = App::new(test_options(false), &config);
2167 assert!(
2168 !app.onboarding_needs_api_key,
2169 "explicit config.api_key must satisfy the onboarding check"
2170 );
2171 }
2172
2173 #[test]
2174 fn new_caches_workspace_skills_for_slash_menu() {
2175 let tmp = tempfile::TempDir::new().expect("tempdir");
2176 let workspace = tmp.path().join("workspace");
2177 let skill_dir = workspace.join(".agents").join("skills").join("local-skill");
2178 std::fs::create_dir_all(&skill_dir).expect("skill dir");
2179 std::fs::write(
2180 skill_dir.join("SKILL.md"),
2181 "---\nname: local-skill\ndescription: Local workspace skill\n---\nUse the local skill.\n",
2182 )
2183 .expect("skill file");
2184
2185 let mut options = test_options(false);
2186 options.workspace = workspace.clone();
2187 options.skills_dir = tmp.path().join("global-skills");
2188 let app = App::new(options, &Config::default());
2189
2190 assert_eq!(app.skills_dir, workspace.join(".agents").join("skills"));
2191 assert!(app.cached_skills.iter().any(|(name, description)| {
2192 name == "local-skill" && description == "Local workspace skill"
2193 }));
2194 }
2195
2196 #[test]
2197 fn cached_skills_merges_across_candidate_directories() {
2198 let tmp = tempfile::TempDir::new().expect("tempdir");
2199 let workspace = tmp.path().join("workspace");
2200
2201 // Higher-precedence directory contains a stale empty dir for `foo`
2202 // (no SKILL.md). This used to shadow the real definition further
2203 // down the candidate list when the cache only scanned a single dir.
2204 std::fs::create_dir_all(workspace.join(".agents").join("skills").join("foo"))
2205 .expect("stale empty dir");
2206
2207 // Lower-precedence directory has the real skill.
2208 let real_dir = workspace.join(".claude").join("skills").join("foo");
2209 std::fs::create_dir_all(&real_dir).expect("real skill dir");
2210 std::fs::write(
2211 real_dir.join("SKILL.md"),
2212 "---\nname: foo\ndescription: Real foo skill\n---\nbody\n",
2213 )
2214 .expect("skill file");
2215
2216 let mut options = test_options(false);
2217 options.workspace = workspace.clone();
2218 options.skills_dir = tmp.path().join("global-skills");
2219 let app = App::new(options, &Config::default());
2220
2221 assert!(
2222 app.cached_skills
2223 .iter()
2224 .any(|(name, description)| name == "foo" && description == "Real foo skill"),
2225 "cached_skills should fall through to lower-precedence dir when higher-precedence one has an empty stub: {:?}",
2226 app.cached_skills,
2227 );
2228 }
2229
2230 #[test]
2231 fn cached_skills_respect_codewhale_only_scan_config() {
2232 let tmp = tempfile::TempDir::new().expect("tempdir");
2233 let workspace = tmp.path().join("workspace");
2234
2235 let claude_dir = workspace
2236 .join(".claude")
2237 .join("skills")
2238 .join("claude-skill");
2239 std::fs::create_dir_all(&claude_dir).expect("claude skill dir");
2240 std::fs::write(
2241 claude_dir.join("SKILL.md"),
2242 "---\nname: claude-skill\ndescription: Claude skill\n---\nbody\n",
2243 )
2244 .expect("write claude skill");
2245
2246 let codewhale_dir = workspace
2247 .join(".codewhale")
2248 .join("skills")
2249 .join("codewhale-skill");
2250 std::fs::create_dir_all(&codewhale_dir).expect("codewhale skill dir");
2251 std::fs::write(
2252 codewhale_dir.join("SKILL.md"),
2253 "---\nname: codewhale-skill\ndescription: CodeWhale skill\n---\nbody\n",
2254 )
2255 .expect("write codewhale skill");
2256
2257 let mut options = test_options(false);
2258 options.workspace = workspace.clone();
2259 options.skills_dir = tmp.path().join("global-skills");
2260 let app = App::new(
2261 options,
2262 &Config {
2263 skills: Some(crate::config::SkillsConfig {
2264 scan_codewhale_only: Some(true),
2265 ..Default::default()
2266 }),
2267 ..Default::default()
2268 },
2269 );
2270
2271 assert_eq!(app.skills_dir, workspace.join(".codewhale").join("skills"));
2272 assert!(
2273 app.cached_skills
2274 .iter()
2275 .any(|(name, _)| name == "codewhale-skill"),
2276 "CodeWhale skill should be cached: {:?}",
2277 app.cached_skills
2278 );
2279 assert!(
2280 !app.cached_skills
2281 .iter()
2282 .any(|(name, _)| name == "claude-skill"),
2283 "strict scan should not cache Claude skills: {:?}",
2284 app.cached_skills
2285 );
2286 }
2287
2288 #[test]
2289 fn resolve_skills_dir_requires_codewhale_skills_to_be_directory() {
2290 let tmp = tempfile::TempDir::new().expect("tempdir");
2291 let workspace = tmp.path().join("workspace");
2292 std::fs::create_dir_all(workspace.join(".codewhale")).expect("codewhale dir");
2293 std::fs::write(
2294 workspace.join(".codewhale").join("skills"),
2295 "not a directory",
2296 )
2297 .expect("skills file");
2298
2299 let global_skills_dir = tmp.path().join("global-skills");
2300 let config = Config {
2301 skills: Some(crate::config::SkillsConfig {
2302 scan_codewhale_only: Some(true),
2303 ..Default::default()
2304 }),
2305 ..Default::default()
2306 };
2307
2308 let resolved = resolve_skills_dir(&workspace, &global_skills_dir, &config);
2309
2310 assert_eq!(resolved, global_skills_dir);
2311 }
2312
2313 #[test]
2314 fn cached_skills_include_configured_directory() {
2315 let tmp = tempfile::TempDir::new().expect("tempdir");
2316 let workspace = tmp.path().join("workspace");
2317
2318 let configured_dir = tmp.path().join("configured-skills");
2319 let configured_skill_dir = configured_dir.join("configured-skill");
2320 std::fs::create_dir_all(&configured_skill_dir).expect("configured skill dir");
2321 std::fs::write(
2322 configured_skill_dir.join("SKILL.md"),
2323 "---\nname: configured-skill\ndescription: Configured skill\n---\nbody\n",
2324 )
2325 .expect("write configured skill");
2326
2327 let mut options = test_options(false);
2328 options.workspace = workspace.clone();
2329 options.skills_dir = configured_dir.clone();
2330 let config = Config {
2331 skills_dir: Some(configured_dir.to_string_lossy().into_owned()),
2332 ..Default::default()
2333 };
2334 let app = App::new(options, &config);
2335
2336 assert!(
2337 app.cached_skills
2338 .iter()
2339 .any(|(name, description)| name == "configured-skill"
2340 && description == "Configured skill"),
2341 "configured skill dir should be merged: {:?}",
2342 app.cached_skills
2343 );
2344 }
2345
2346 #[test]
2347 fn cached_skills_preserve_configured_directory_in_codewhale_only_scan() {
2348 let tmp = tempfile::TempDir::new().expect("tempdir");
2349 let workspace = tmp.path().join("workspace");
2350
2351 let codewhale_skill_dir = workspace
2352 .join(".codewhale")
2353 .join("skills")
2354 .join("workspace-codewhale");
2355 std::fs::create_dir_all(&codewhale_skill_dir).expect("workspace codewhale skill dir");
2356 std::fs::write(
2357 codewhale_skill_dir.join("SKILL.md"),
2358 "---\nname: workspace-codewhale\ndescription: Workspace CodeWhale skill\n---\nbody\n",
2359 )
2360 .expect("write workspace codewhale skill");
2361
2362 let configured_dir = tmp.path().join("configured-skills");
2363 let configured_skill_dir = configured_dir.join("configured-skill");
2364 std::fs::create_dir_all(&configured_skill_dir).expect("configured skill dir");
2365 std::fs::write(
2366 configured_skill_dir.join("SKILL.md"),
2367 "---\nname: configured-skill\ndescription: Configured skill\n---\nbody\n",
2368 )
2369 .expect("write configured skill");
2370
2371 let mut options = test_options(false);
2372 options.workspace = workspace.clone();
2373 options.skills_dir = configured_dir.clone();
2374 let config = Config {
2375 skills_dir: Some(configured_dir.to_string_lossy().into_owned()),
2376 skills: Some(crate::config::SkillsConfig {
2377 scan_codewhale_only: Some(true),
2378 ..Default::default()
2379 }),
2380 ..Default::default()
2381 };
2382 let app = App::new(options, &config);
2383
2384 assert_eq!(app.skills_dir, configured_dir);
2385 assert!(
2386 app.cached_skills
2387 .iter()
2388 .any(|(name, _)| name == "workspace-codewhale"),
2389 "workspace CodeWhale skill should still be cached: {:?}",
2390 app.cached_skills
2391 );
2392 assert!(
2393 app.cached_skills
2394 .iter()
2395 .any(|(name, _)| name == "configured-skill"),
2396 "explicit configured skills_dir should still be cached: {:?}",
2397 app.cached_skills
2398 );
2399 }
2400
2401 #[test]
2402 fn cached_skills_reject_codewhale_only_workspace_symlink_escape() {
2403 let tmp = tempfile::TempDir::new().expect("tempdir");
2404 let workspace = tmp.path().join("workspace");
2405 let escape_target = tmp.path().join("escape-target");
2406 let escaped_skill_dir = escape_target.join("escaped-skill");
2407 std::fs::create_dir_all(workspace.join(".codewhale")).expect("codewhale dir");
2408 std::fs::create_dir_all(&escaped_skill_dir).expect("escaped skill dir");
2409 std::fs::write(
2410 escaped_skill_dir.join("SKILL.md"),
2411 "---\nname: escaped-skill\ndescription: Escaped skill\n---\nbody\n",
2412 )
2413 .expect("write escaped skill");
2414
2415 let link_path = workspace.join(".codewhale").join("skills");
2416 if create_dir_symlink(&escape_target, &link_path).is_err() {
2417 return;
2418 }
2419
2420 let global_skills_dir = tmp.path().join("global-skills");
2421 let mut options = test_options(false);
2422 options.workspace = workspace.clone();
2423 options.skills_dir = global_skills_dir.clone();
2424 let config = Config {
2425 skills: Some(crate::config::SkillsConfig {
2426 scan_codewhale_only: Some(true),
2427 ..Default::default()
2428 }),
2429 ..Default::default()
2430 };
2431 let app = App::new(options, &config);
2432
2433 assert_eq!(app.skills_dir, global_skills_dir);
2434 assert!(
2435 !app.cached_skills
2436 .iter()
2437 .any(|(name, _)| name == "escaped-skill"),
2438 "strict app cache must not follow escaped workspace CodeWhale symlinks: {:?}",
2439 app.cached_skills
2440 );
2441 }
2442
2443 #[test]
2444 fn paste_defers_oversized_text_consolidation_until_submit() {
2445 // (#3263): a large paste stays inline so the user can still edit it.
2446 // At submit time, the full text is sent to the model with the @mention
2447 // appended so the model can also read the paste file backup.
2448 let tmp = tempfile::TempDir::new().expect("tempdir");
2449 let mut opts = test_options(false);
2450 opts.workspace = tmp.path().to_path_buf();
2451 let mut app = App::new(opts, &Config::default());
2452 let full_content = "y".repeat(MAX_SUBMITTED_INPUT_CHARS + 256);
2453
2454 app.insert_paste_text(&full_content);
2455
2456 assert_eq!(app.input, full_content);
2457 assert_eq!(app.cursor_position, app.input.chars().count());
2458 let pastes_dir = tmp.path().join(".codewhale/pastes");
2459 assert!(
2460 !pastes_dir.exists() || std::fs::read_dir(&pastes_dir).unwrap().next().is_none(),
2461 "paste file should not be written before submit"
2462 );
2463 assert!(
2464 app.status_toasts
2465 .iter()
2466 .all(|toast| !toast.text.contains("backed up")),
2467 "backup toast should not appear before submit"
2468 );
2469
2470 let submitted = app.submit_input().expect("expected submitted input");
2471 // The submitted text should contain the original content with the
2472 // @mention appended at the end (#3263).
2473 assert!(
2474 submitted.starts_with(&full_content),
2475 "submitted should contain full content, got: {}",
2476 &submitted[..submitted.len().min(80)]
2477 );
2478 let mention_start = full_content.len();
2479 assert!(
2480 submitted[mention_start..].starts_with("\n@.codewhale/pastes/paste-"),
2481 "expected @mention suffix, got: {}",
2482 &submitted[mention_start..]
2483 );
2484 assert!(submitted.ends_with(".md"), "expected .md extension");
2485 let mention = &submitted[mention_start + 2..]; // strip '\n@'
2486 let abs = tmp.path().join(mention);
2487 assert!(abs.is_file(), "paste file must exist at {abs:?}");
2488 let written = std::fs::read_to_string(&abs).expect("read");
2489 assert_eq!(written, full_content);
2490 assert!(
2491 app.status_toasts
2492 .iter()
2493 .any(|toast| toast.text.contains("backed up")),
2494 "expected backup toast after submit"
2495 );
2496 }
2497
2498 #[test]
2499 fn paste_under_threshold_does_not_consolidate() {
2500 // Negative path: a small paste must NOT spawn a paste file. The
2501 // input stays inline so the user can edit it freely.
2502 let tmp = tempfile::TempDir::new().expect("tempdir");
2503 let mut opts = test_options(false);
2504 opts.workspace = tmp.path().to_path_buf();
2505 let mut app = App::new(opts, &Config::default());
2506 let small = "hello world\nthis is fine".to_string();
2507
2508 app.insert_paste_text(&small);
2509
2510 assert_eq!(app.input, small);
2511 assert!(!app.input.starts_with("@.codewhale/pastes/"));
2512 // No paste file gets written for under-cap pastes.
2513 let pastes_dir = tmp.path().join(".codewhale/pastes");
2514 assert!(
2515 !pastes_dir.exists() || std::fs::read_dir(&pastes_dir).unwrap().next().is_none(),
2516 "no paste file should be written for under-cap content"
2517 );
2518 }
2519
2520 #[test]
2521 fn large_multiline_paste_preserves_exact_bytes_through_submit() {
2522 // #4719: large multi-line pastes must not byte-corrupt before submission.
2523 // Real dogfood saw paths like `codewhale-v091-exact-88a158-ci` arrive as
2524 // `work-88a158-ci` — assert exact fidelity for a representative payload.
2525 let tmp = tempfile::TempDir::new().expect("tempdir");
2526 let mut opts = test_options(false);
2527 opts.workspace = tmp.path().to_path_buf();
2528 let mut app = App::new(opts, &Config::default());
2529
2530 let payload = format!(
2531 "Mission path: /Volumes/VIXinSSD/CW/worktrees/codewhale-v091-exact-88a158-ci\n\
2532 SHA: 0dfe9170a10e081fe48b23239f22d33260f4fa24\n\
2533 Branch: codex/v091-local-candidate-20260722\n\
2534 Paths that must not truncate: codewhale-v091-exact-88a158-ci worktrees/codewhale-v091-exact-88a158-ci\n\
2535 Mixed punctuation: a;b:c[m]<n> digits 0123456789 and hyphens-ok\n\
2536 Unicode: 你好世界 café — keep every codepoint.\n\
2537 {}",
2538 "line-body-".repeat(200)
2539 );
2540 // Stay under MAX_SUBMITTED_INPUT_CHARS so submit returns the inline text
2541 // (no @paste consolidation) and we can compare exact bytes.
2542 assert!(
2543 payload.chars().count() < MAX_SUBMITTED_INPUT_CHARS,
2544 "fixture must stay under submit consolidation threshold"
2545 );
2546
2547 app.insert_paste_text(&payload);
2548 assert_eq!(
2549 app.input, payload,
2550 "composer input must equal pasted payload exactly"
2551 );
2552
2553 let submitted = app.submit_input().expect("submit");
2554 assert_eq!(
2555 submitted, payload,
2556 "submitted bytes must equal pasted payload exactly"
2557 );
2558 }
2559
2560 #[test]
2561 fn submit_input_consolidates_oversized_input_into_paste_file() {
2562 let tmp = tempfile::TempDir::new().expect("tempdir");
2563 let mut opts = test_options(false);
2564 opts.workspace = tmp.path().to_path_buf();
2565 let mut app = App::new(opts, &Config::default());
2566 let full_content = "x".repeat(MAX_SUBMITTED_INPUT_CHARS + 128);
2567 app.input = full_content.clone();
2568 app.cursor_position = app.input.chars().count();
2569
2570 let submitted = app.submit_input().expect("expected submitted input");
2571
2572 // The submitted text should still contain the original content, with
2573 // the @mention appended at the end so the model can read the file
2574 // while the composer stays editable for the user (#3263).
2575 assert!(
2576 submitted.starts_with(&full_content),
2577 "submitted text should contain original content, got: {}",
2578 &submitted[..submitted.len().min(80)]
2579 );
2580 let mention_start = full_content.len();
2581 assert!(
2582 submitted[mention_start..].starts_with("\n@.codewhale/pastes/paste-"),
2583 "submitted text should end with @mention, got suffix: {}",
2584 &submitted[mention_start..]
2585 );
2586 assert!(
2587 submitted.ends_with(".md"),
2588 "expected .md extension, got: {submitted}"
2589 );
2590
2591 // The paste file must exist on disk with the full original content.
2592 let mention = &submitted[mention_start + 2..]; // strip leading '\n@'
2593 let abs_path = tmp.path().join(mention);
2594 assert!(abs_path.is_file(), "paste file must exist at {abs_path:?}");
2595 let written = std::fs::read_to_string(&abs_path).expect("read paste file");
2596 assert_eq!(written, full_content);
2597
2598 // A status toast should have been pushed.
2599 assert!(
2600 app.status_toasts
2601 .iter()
2602 .any(|toast| toast.text.contains("backed up")),
2603 "expected backup toast, got: {:?}",
2604 app.status_toasts
2605 .iter()
2606 .map(|t| &t.text)
2607 .collect::<Vec<_>>()
2608 );
2609
2610 // The composer must be clear after submit.
2611 assert!(app.input.is_empty());
2612 }
2613
2614 #[test]
2615 fn app_starts_without_seeded_transcript_messages() {
2616 let app = App::new(test_options(false), &Config::default());
2617 assert!(app.history.is_empty());
2618 assert_eq!(app.history_version, 0);
2619 }
2620
2621 #[test]
2622 fn clear_todos_resets_todos_list() {
2623 let mut app = App::new(test_options(false), &Config::default());
2624
2625 // Seed some todos.
2626 {
2627 let mut todos = app.todos.try_lock().expect("todos lock");
2628 todos.add("buy milk".to_string(), TodoStatus::Pending);
2629 todos.add("write code".to_string(), TodoStatus::InProgress);
2630 assert_eq!(todos.snapshot().items.len(), 2);
2631 }
2632
2633 assert!(app.clear_todos());
2634
2635 let todos = app.todos.try_lock().expect("todos lock");
2636 assert!(todos.snapshot().items.is_empty());
2637 }
2638
2639 #[test]
2640 fn clear_todos_resets_plan_state() {
2641 let mut app = App::new(test_options(false), &Config::default());
2642
2643 {
2644 let mut plan = app
2645 .plan_state
2646 .try_lock()
2647 .expect("plan lock should be available");
2648 plan.update(UpdatePlanArgs {
2649 explanation: Some("test plan".to_string()),
2650 plan: vec![PlanItemArg {
2651 step: "step 1".to_string(),
2652 status: StepStatus::InProgress,
2653 }],
2654 ..UpdatePlanArgs::default()
2655 });
2656 assert!(!plan.snapshot().is_empty());
2657 }
2658
2659 assert!(app.clear_todos());
2660
2661 let plan = app
2662 .plan_state
2663 .try_lock()
2664 .expect("plan lock should be available");
2665 assert!(plan.snapshot().is_empty());
2666 }
2667
2668 #[test]
2669 fn work_state_snapshot_round_trips_todos_and_plan() {
2670 let app = App::new(test_options(false), &Config::default());
2671 {
2672 let mut todos = app.todos.try_lock().expect("todos lock");
2673 todos.add("inspect".to_string(), TodoStatus::Completed);
2674 todos.add("patch".to_string(), TodoStatus::InProgress);
2675 }
2676 {
2677 let mut plan = app.plan_state.try_lock().expect("plan lock");
2678 plan.update(UpdatePlanArgs {
2679 objective: Some("Keep Work durable".to_string()),
2680 plan: vec![PlanItemArg {
2681 step: "verify".to_string(),
2682 status: StepStatus::InProgress,
2683 }],
2684 ..UpdatePlanArgs::default()
2685 });
2686 }
2687 let state = app
2688 .work_state_snapshot()
2689 .expect("snapshot locks")
2690 .expect("non-empty state");
2691
2692 let mut restored = App::new(test_options(false), &Config::default());
2693 let restored_workspace = restored.workspace.clone();
2694 restored
2695 .restore_work_state("restored-session", &restored_workspace, Some(&state))
2696 .expect("restore Work state");
2697 assert_eq!(
2698 restored.work_state_snapshot().expect("snapshot"),
2699 Some(state)
2700 );
2701 }
2702
2703 #[test]
2704 fn work_restore_reconciles_fleet_from_the_restored_workspace() {
2705 let restored_workspace = tempfile::tempdir().expect("restored workspace");
2706 let ledger = crate::fleet::ledger::FleetLedger::open(restored_workspace.path())
2707 .expect("open restored Fleet ledger");
2708 ledger
2709 .enqueue(codewhale_protocol::fleet::FleetInboxEntry {
2710 run_id: codewhale_protocol::fleet::FleetRunId::from("run-restore"),
2711 task_id: "task-restore".to_string(),
2712 priority: 0,
2713 enqueued_at: "2026-07-18T00:00:00Z".to_string(),
2714 lease_deadline: None,
2715 attempts: 0,
2716 })
2717 .expect("enqueue restored Fleet task");
2718
2719 let source = crate::work_graph::new_shared_work_runtime(
2720 crate::tools::todo::new_shared_todo_list(),
2721 crate::tools::plan::new_shared_plan_state(),
2722 );
2723 source
2724 .register_operation(
2725 "restored-session",
2726 crate::work_graph::OperationIntent::new(
2727 "fleet:run-restore/task-restore",
2728 "restored Fleet task",
2729 true,
2730 "fleet",
2731 "restore-test",
2732 ),
2733 )
2734 .expect("register Fleet binding");
2735 let captured = source
2736 .capture(Some("restored-session"))
2737 .expect("capture source Work state")
2738 .expect("non-empty source Work state");
2739 let state = crate::session_manager::SessionWorkState {
2740 graph: Some(captured.graph),
2741 todos: captured.todos,
2742 plan: captured.plan,
2743 };
2744
2745 let mut app = App::new(test_options(false), &Config::default());
2746 assert_ne!(app.workspace, restored_workspace.path());
2747 app.restore_work_state("restored-session", restored_workspace.path(), Some(&state))
2748 .expect("restore Work state from target workspace");
2749 let graph = app
2750 .runtime_services
2751 .work
2752 .as_ref()
2753 .expect("Work runtime")
2754 .capture(Some("restored-session"))
2755 .expect("capture restored Work state")
2756 .expect("restored graph")
2757 .graph;
2758 let operation = graph
2759 .nodes
2760 .iter()
2761 .find(|node| {
2762 node.binding
2763 .as_ref()
2764 .is_some_and(|binding| binding.external == "fleet:run-restore/task-restore")
2765 })
2766 .expect("restored Fleet operation");
2767 assert_eq!(
2768 operation.state,
2769 crate::work_graph::NodeState::Initializing,
2770 "the target workspace ledger must outrank the app's previous workspace"
2771 );
2772 }
2773
2774 #[test]
2775 fn failed_workspace_owner_reconcile_leaves_previous_work_state_intact() {
2776 let restored_workspace = tempfile::tempdir().expect("restored workspace");
2777 let ledger = crate::fleet::ledger::FleetLedger::open(restored_workspace.path())
2778 .expect("open restored Fleet ledger");
2779 ledger
2780 .enqueue(codewhale_protocol::fleet::FleetInboxEntry {
2781 run_id: codewhale_protocol::fleet::FleetRunId::from("run-regress"),
2782 task_id: "task-regress".to_string(),
2783 priority: 0,
2784 enqueued_at: "2026-07-18T00:00:00Z".to_string(),
2785 lease_deadline: None,
2786 attempts: 0,
2787 })
2788 .expect("enqueue older Fleet owner state");
2789
2790 let incoming = crate::work_graph::new_shared_work_runtime(
2791 crate::tools::todo::new_shared_todo_list(),
2792 crate::tools::plan::new_shared_plan_state(),
2793 );
2794 incoming
2795 .register_operation(
2796 "incoming-session",
2797 crate::work_graph::OperationIntent::new(
2798 "fleet:run-regress/task-regress",
2799 "newer saved Fleet task",
2800 true,
2801 "fleet",
2802 "regression-test",
2803 ),
2804 )
2805 .expect("register incoming Fleet binding");
2806 incoming
2807 .reconcile_operation(
2808 "incoming-session",
2809 crate::work_graph::OperationOwnerSnapshot::new(
2810 "fleet:run-regress/task-regress",
2811 crate::work_graph::OwnerState::Running,
2812 2,
2813 2,
2814 ),
2815 )
2816 .expect("record newer saved owner sequence");
2817 let incoming = incoming
2818 .capture(Some("incoming-session"))
2819 .expect("capture incoming state")
2820 .expect("incoming graph");
2821 let incoming = crate::session_manager::SessionWorkState {
2822 graph: Some(incoming.graph),
2823 todos: incoming.todos,
2824 plan: incoming.plan,
2825 };
2826
2827 let mut app = App::new(test_options(false), &Config::default());
2828 let work = app
2829 .runtime_services
2830 .work
2831 .as_ref()
2832 .expect("Work runtime")
2833 .clone();
2834 work.register_operation(
2835 "previous-session",
2836 crate::work_graph::OperationIntent::new(
2837 "shell:shell_previous",
2838 "previous operation",
2839 false,
2840 "exec_shell",
2841 "previous-test",
2842 ),
2843 )
2844 .expect("register previous state");
2845 let before = work
2846 .capture(Some("previous-session"))
2847 .expect("capture previous state")
2848 .expect("previous graph");
2849
2850 let error = app
2851 .restore_work_state(
2852 "incoming-session",
2853 restored_workspace.path(),
2854 Some(&incoming),
2855 )
2856 .expect_err("owner sequence regression must fail closed");
2857 assert!(error.contains("sequence regressed"), "{error}");
2858 assert_eq!(
2859 work.capture(Some("previous-session"))
2860 .expect("capture state after failed restore")
2861 .expect("previous graph remains"),
2862 before,
2863 "failed restore must not replace any part of the previous Work state"
2864 );
2865 }
2866
2867 #[test]
2868 fn clear_todos_is_atomic_and_invalidates_cached_work_summary() {
2869 let mut app = App::new(test_options(false), &Config::default());
2870 {
2871 let mut todos = app.todos.try_lock().expect("todos lock");
2872 todos.add("clear me".to_string(), TodoStatus::Pending);
2873 }
2874 app.cached_work_summary = Some(SidebarWorkSummary::default());
2875
2876 assert!(app.clear_todos());
2877 assert!(app.cached_work_summary.is_none());
2878 assert_eq!(app.work_state_snapshot().expect("snapshot"), None);
2879 }
2880
2881 #[test]
2882 fn entering_operate_preserves_user_rail_panel() {
2883 let mut app = App::new(test_options(false), &Config::default());
2884 app.work_surface.panel = crate::tui::work_surface::RailPanel::Agents;
2885
2886 assert!(app.set_mode(AppMode::Operate));
2887 assert_eq!(
2888 app.work_surface.panel,
2889 crate::tui::work_surface::RailPanel::Agents
2890 );
2891 }
2892
2893 #[test]
2894 fn app_mode_helpers_centralize_parse_labels_and_cycle_order() {
2895 assert_eq!(AppMode::parse("agent"), Some(AppMode::Agent));
2896 assert_eq!(AppMode::parse("act"), Some(AppMode::Agent));
2897 assert_eq!(AppMode::parse("2"), Some(AppMode::Plan));
2898 assert_eq!(AppMode::parse("auto"), Some(AppMode::Agent));
2899 assert_eq!(AppMode::parse("3"), Some(AppMode::Operate));
2900 assert_eq!(AppMode::parse("operate"), Some(AppMode::Operate));
2901 assert_eq!(AppMode::parse("YOLO"), Some(AppMode::Yolo));
2902 assert_eq!(AppMode::parse("4"), Some(AppMode::Yolo));
2903 assert_eq!(AppMode::parse("multitask"), None);
2904 assert_eq!(AppMode::parse("5"), None);
2905 assert_eq!(AppMode::parse("fast"), None);
2906 assert_eq!(AppMode::from_setting("multitask"), AppMode::Operate);
2907 assert_eq!(AppMode::from_setting("5"), AppMode::Operate);
2908
2909 assert_eq!(AppMode::Agent.as_setting(), "agent");
2910 assert_eq!(AppMode::Auto.as_setting(), "agent");
2911 assert_eq!(AppMode::Yolo.as_setting(), "agent");
2912 assert_eq!(AppMode::Plan.display_name(), "Plan");
2913 assert_eq!(AppMode::Auto.display_name(), "Act");
2914 assert_eq!(AppMode::Auto.label(), "ACT");
2915 assert_eq!(AppMode::Yolo.label(), "ACT");
2916 assert_eq!(AppMode::Yolo.display_name(), "Act");
2917 assert_eq!(AppMode::Agent.number(), '1');
2918 assert_eq!(AppMode::Auto.number(), '1');
2919 assert_eq!(AppMode::Yolo.number(), '1');
2920 assert_eq!(AppMode::Operate.number(), '3');
2921 assert_eq!(
2922 AppMode::CYCLE,
2923 [AppMode::Plan, AppMode::Agent, AppMode::Operate]
2924 );
2925
2926 assert_eq!(AppMode::Plan.next(), AppMode::Agent);
2927 assert_eq!(AppMode::Agent.next(), AppMode::Operate);
2928 assert_eq!(AppMode::Operate.next(), AppMode::Plan);
2929 assert_eq!(AppMode::Auto.next(), AppMode::Agent);
2930 assert_eq!(AppMode::Yolo.next(), AppMode::Agent);
2931 assert_eq!(AppMode::Plan.previous(), AppMode::Operate);
2932 assert_eq!(AppMode::Agent.previous(), AppMode::Plan);
2933 assert_eq!(AppMode::Operate.previous(), AppMode::Agent);
2934 assert_eq!(AppMode::Auto.previous(), AppMode::Agent);
2935 assert_eq!(AppMode::Yolo.previous(), AppMode::Agent);
2936 }
2937
2938 #[test]
2939 fn test_cycle_mode_transitions() {
2940 let mut app = App::new(test_options(false), &Config::default());
2941 let initial_mode = app.mode;
2942 app.cycle_mode();
2943 // Mode should have changed
2944 assert_ne!(app.mode, initial_mode);
2945 }
2946
2947 #[test]
2948 fn effective_route_display_tracks_inflight_and_last_auto_provider() {
2949 let mut app = App::new(test_options(false), &Config::default());
2950 app.auto_model = true;
2951 app.pending_turn_route = Some((ApiProvider::Zai, "glm-5.2".to_string(), true));
2952 assert_eq!(
2953 app.effective_route_display(),
2954 (ApiProvider::Zai, "glm-5.2".to_string())
2955 );
2956
2957 app.pending_turn_route = None;
2958 app.last_effective_provider = Some(ApiProvider::Xai);
2959 app.last_effective_model = Some("grok-4.5".to_string());
2960 assert_eq!(
2961 app.effective_route_display(),
2962 (ApiProvider::Xai, "grok-4.5".to_string())
2963 );
2964 }
2965
2966 #[test]
2967 fn test_cycle_mode_reverse_transitions() {
2968 let mut app = App::new(test_options(false), &Config::default());
2969
2970 app.mode = AppMode::Plan;
2971 app.cycle_mode_reverse();
2972 assert_eq!(app.mode, AppMode::Operate);
2973
2974 app.mode = AppMode::Operate;
2975 app.cycle_mode_reverse();
2976 assert_eq!(app.mode, AppMode::Agent);
2977
2978 app.mode = AppMode::Agent;
2979 app.cycle_mode_reverse();
2980 assert_eq!(app.mode, AppMode::Plan);
2981
2982 app.mode = AppMode::Auto;
2983 app.cycle_mode_reverse();
2984 assert_eq!(app.mode, AppMode::Agent);
2985 }
2986
2987 #[test]
2988 fn test_mode_switch_does_not_emit_redundant_toast() {
2989 let mut app = App::new(test_options(false), &Config::default());
2990 let first_mode = app.mode.next();
2991 let second_mode = first_mode.next();
2992
2993 app.set_mode(first_mode);
2994 app.sync_status_message_to_toasts();
2995 assert!(app.status_toasts.is_empty());
2996
2997 app.set_mode(second_mode);
2998 app.sync_status_message_to_toasts();
2999 assert!(app.status_toasts.is_empty());
3000 }
3001
3002 #[test]
3003 fn test_mode_switch_toasts_do_not_disrupt_non_mode_toasts() {
3004 let mut app = App::new(test_options(false), &Config::default());
3005 app.yolo_compat_notified = true;
3006 app.status_message = Some("Task queued".to_string());
3007 app.sync_status_message_to_toasts();
3008
3009 app.set_mode(AppMode::Agent);
3010 app.sync_status_message_to_toasts();
3011 app.set_mode(AppMode::Yolo);
3012 app.sync_status_message_to_toasts();
3013
3014 assert_eq!(app.status_toasts.len(), 1);
3015 assert!(
3016 app.status_toasts
3017 .iter()
3018 .any(|toast| toast.text == "Task queued")
3019 );
3020 }
3021
3022 #[test]
3023 fn test_clear_input() {
3024 let mut app = App::new(test_options(false), &Config::default());
3025 app.input = "test input".to_string();
3026 app.cursor_position = app.input.len();
3027 app.clear_input();
3028 assert!(app.input.is_empty());
3029 assert_eq!(app.cursor_position, 0);
3030 }
3031
3032 #[test]
3033 fn test_queue_message() {
3034 let mut app = App::new(test_options(false), &Config::default());
3035 app.queue_message(QueuedMessage::new("test message".to_string(), None));
3036 assert_eq!(app.queued_message_count(), 1);
3037 assert!(app.queued_messages.front().is_some());
3038 }
3039
3040 #[test]
3041 fn test_remove_queued_message() {
3042 let mut app = App::new(test_options(false), &Config::default());
3043 app.queue_message(QueuedMessage::new("first".to_string(), None));
3044 app.queue_message(QueuedMessage::new("second".to_string(), None));
3045
3046 // Remove first (index 0)
3047 let removed = app.remove_queued_message(0);
3048 assert!(removed.is_some());
3049 assert_eq!(app.queued_message_count(), 1);
3050
3051 // Remove second (now at index 0)
3052 let removed = app.remove_queued_message(0);
3053 assert!(removed.is_some());
3054 assert_eq!(app.queued_message_count(), 0);
3055 }
3056
3057 #[test]
3058 fn test_remove_queued_message_invalid_index() {
3059 let mut app = App::new(test_options(false), &Config::default());
3060 app.queue_message(QueuedMessage::new("test".to_string(), None));
3061
3062 // Try to remove non-existent index
3063 let removed = app.remove_queued_message(100);
3064 assert!(removed.is_none());
3065 }
3066
3067 #[test]
3068 fn test_set_mode_updates_state() {
3069 let mut app = App::new(test_options(false), &Config::default());
3070 app.yolo_compat_notified = true;
3071 app.set_mode(AppMode::Plan);
3072 assert_eq!(app.mode, AppMode::Plan);
3073 // The deprecated YOLO alias remaps to Agent (M6 back-compat shim).
3074 app.set_mode(AppMode::Yolo);
3075 assert_eq!(app.mode, AppMode::Agent);
3076 assert!(app.yolo);
3077 // YOLO compat shim should enable trust, shell, and bypass approvals.
3078 assert!(app.trust_mode);
3079 assert!(app.allow_shell);
3080 assert_eq!(app.approval_mode, ApprovalMode::Bypass);
3081 }
3082
3083 #[test]
3084 fn app_new_respects_allow_shell_option_when_not_yolo() {
3085 let mut options = test_options(false);
3086 options.allow_shell = false;
3087 options.start_in_agent_mode = true; // avoid coupling to settings.default_mode
3088 let app = App::new(options, &Config::default());
3089 assert!(!app.allow_shell);
3090 }
3091
3092 #[test]
3093 fn set_mode_yolo_restores_previous_policies_on_exit() {
3094 let mut options = test_options(false);
3095 options.allow_shell = false;
3096 options.start_in_agent_mode = true; // avoid coupling to settings.default_mode
3097 let mut app = App::new(options, &Config::default());
3098 app.allow_shell = false;
3099 app.trust_mode = false;
3100 app.approval_mode = ApprovalMode::Never;
3101 app.yolo_compat_notified = true;
3102
3103 app.set_mode(AppMode::Yolo);
3104 assert!(app.allow_shell);
3105 assert!(app.trust_mode);
3106 assert_eq!(app.approval_mode, ApprovalMode::Bypass);
3107
3108 app.set_mode(AppMode::Agent);
3109 assert!(!app.allow_shell);
3110 assert!(!app.trust_mode);
3111 assert_eq!(app.approval_mode, ApprovalMode::Never);
3112 }
3113
3114 #[test]
3115 fn set_mode_plan_restores_previous_approval_on_agent_exit() {
3116 let config = Config {
3117 approval_policy: Some("never".to_string()),
3118 ..Default::default()
3119 };
3120 let mut options = test_options(false);
3121 options.start_in_agent_mode = true; // avoid coupling to settings.default_mode
3122 let mut app = App::new(options, &config);
3123 assert_eq!(app.mode, AppMode::Agent);
3124 assert_eq!(app.approval_mode, ApprovalMode::Never);
3125
3126 app.set_mode(AppMode::Plan);
3127 app.approval_mode = ApprovalMode::Suggest;
3128
3129 app.set_mode(AppMode::Agent);
3130 assert_eq!(app.mode, AppMode::Agent);
3131 assert_eq!(app.approval_mode, ApprovalMode::Never);
3132 }
3133
3134 #[test]
3135 fn set_mode_plan_to_yolo_keeps_yolo_permissions_and_restores_agent_baseline() {
3136 let mut options = test_options(false);
3137 options.allow_shell = false;
3138 options.start_in_agent_mode = true; // avoid coupling to settings.default_mode
3139 let mut app = App::new(options, &Config::default());
3140 app.allow_shell = false;
3141 app.trust_mode = false;
3142 app.approval_mode = ApprovalMode::Never;
3143 app.yolo_compat_notified = true;
3144
3145 app.set_mode(AppMode::Plan);
3146 app.approval_mode = ApprovalMode::Suggest;
3147
3148 app.set_mode(AppMode::Yolo);
3149 assert_eq!(app.mode, AppMode::Agent);
3150 assert!(app.allow_shell);
3151 assert!(app.trust_mode);
3152 assert_eq!(app.approval_mode, ApprovalMode::Bypass);
3153
3154 app.set_mode(AppMode::Agent);
3155 assert_eq!(app.mode, AppMode::Agent);
3156 assert!(!app.allow_shell);
3157 assert!(!app.trust_mode);
3158 assert_eq!(app.approval_mode, ApprovalMode::Never);
3159 }
3160
3161 #[test]
3162 fn base_policy_for_mode_projects_the_mode_permission_table() {
3163 // Pure projection of (mode, prefs) — the single source of truth for #3386.
3164 let prefs = ModeSessionPrefs {
3165 agent_allow_shell: true,
3166 agent_trust_mode: true,
3167 agent_approval_mode: ApprovalMode::Never,
3168 };
3169
3170 // Plan: read-only, no shell, no trust, Suggest — and it never inherits the
3171 // (here elevated) Agent baseline.
3172 let plan = base_policy_for_mode(AppMode::Plan, &prefs);
3173 assert_eq!(plan.mode, AppMode::Plan);
3174 assert!(!plan.allow_shell);
3175 assert!(!plan.trust_mode);
3176 assert_eq!(plan.approval_mode, ApprovalMode::Suggest);
3177
3178 // Agent: exactly the durable baseline.
3179 let agent = base_policy_for_mode(AppMode::Agent, &prefs);
3180 assert_eq!(agent.mode, AppMode::Agent);
3181 assert!(agent.allow_shell);
3182 assert!(agent.trust_mode);
3183 assert_eq!(agent.approval_mode, ApprovalMode::Never);
3184
3185 // Auto: compatibility alias for the durable Agent baseline.
3186 let auto = base_policy_for_mode(AppMode::Auto, &prefs);
3187 assert_eq!(auto.mode, AppMode::Auto);
3188 assert!(auto.allow_shell);
3189 assert!(auto.trust_mode);
3190 assert_eq!(auto.approval_mode, ApprovalMode::Never);
3191
3192 // Operate uses the Agent baseline.
3193 let operate = base_policy_for_mode(AppMode::Operate, &prefs);
3194 assert_eq!(operate.mode, AppMode::Operate);
3195 assert_eq!(operate.allow_shell, agent.allow_shell);
3196 assert_eq!(operate.trust_mode, agent.trust_mode);
3197 assert_eq!(operate.approval_mode, ApprovalMode::Never);
3198
3199 // YOLO: full authority is represented by Bypass, not a separate
3200 // auto-approve field (#3736).
3201 let yolo = base_policy_for_mode(AppMode::Yolo, &prefs);
3202 assert_eq!(yolo.mode, AppMode::Yolo);
3203 assert!(yolo.allow_shell);
3204 assert!(yolo.trust_mode);
3205 assert_eq!(yolo.approval_mode, ApprovalMode::Bypass);
3206
3207 // A minimal Agent baseline projects through Agent unchanged.
3208 let minimal = ModeSessionPrefs {
3209 agent_allow_shell: false,
3210 agent_trust_mode: false,
3211 agent_approval_mode: ApprovalMode::Suggest,
3212 };
3213 let agent_min = base_policy_for_mode(AppMode::Agent, &minimal);
3214 assert!(!agent_min.allow_shell);
3215 assert!(!agent_min.trust_mode);
3216 assert_eq!(agent_min.approval_mode, ApprovalMode::Suggest);
3217 let operate_min = base_policy_for_mode(AppMode::Operate, &minimal);
3218 assert!(!operate_min.allow_shell);
3219 assert!(!operate_min.trust_mode);
3220 assert_eq!(operate_min.approval_mode, ApprovalMode::Suggest);
3221 }
3222
3223 #[test]
3224 fn cycle_approval_posture_cycles_suggest_auto_bypass() {
3225 let _env_lock = lock_test_env();
3226 let tmp = tempfile::tempdir().expect("tempdir");
3227 let config_path = tmp.path().join("config.toml");
3228 let _config_env = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
3229 let mut options = test_options(false);
3230 options.start_in_agent_mode = true;
3231 options.config_path = Some(config_path);
3232 let mut app = App::new(options, &Config::default());
3233 app.approval_mode = ApprovalMode::Suggest;
3234
3235 assert!(app.cycle_approval_posture());
3236 assert_eq!(app.approval_mode, ApprovalMode::Auto);
3237
3238 assert!(app.cycle_approval_posture());
3239 assert_eq!(app.approval_mode, ApprovalMode::Bypass);
3240
3241 assert!(app.cycle_approval_posture());
3242 assert_eq!(app.approval_mode, ApprovalMode::Suggest);
3243 let persisted = std::fs::read_to_string(tmp.path().join("settings.toml")).expect("settings");
3244 assert!(persisted.contains("permission_posture = \"ask\""));
3245 }
3246
3247 #[test]
3248 fn cycle_approval_posture_emits_rebinding_notice_once() {
3249 let _env_lock = lock_test_env();
3250 let tmp = tempfile::tempdir().expect("tempdir");
3251 let config_path = tmp.path().join("config.toml");
3252 let _config_env = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
3253 let mut options = test_options(false);
3254 options.start_in_agent_mode = true;
3255 options.config_path = Some(config_path);
3256 let mut app = App::new(options, &Config::default());
3257
3258 assert!(app.cycle_approval_posture());
3259 let notices = app
3260 .status_toasts
3261 .iter()
3262 .filter(|toast| toast.text.contains("moved to Ctrl+T"))
3263 .count();
3264 assert_eq!(notices, 1, "first cycle posts the rebinding notice");
3265
3266 assert!(app.cycle_approval_posture());
3267 let notices = app
3268 .status_toasts
3269 .iter()
3270 .filter(|toast| toast.text.contains("moved to Ctrl+T"))
3271 .count();
3272 assert_eq!(notices, 1, "notice is one-shot per session");
3273 }
3274
3275 #[test]
3276 fn plan_permission_cycle_is_rejected_without_mutating_agent_baseline() {
3277 let _env_lock = lock_test_env();
3278 let tmp = tempfile::tempdir().expect("tempdir");
3279 let config_path = tmp.path().join("config.toml");
3280 let _config_env = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
3281 let mut options = test_options(false);
3282 options.config_path = Some(config_path);
3283 let mut app = App::new(options, &Config::default());
3284 app.set_agent_approval_posture(ApprovalMode::Auto);
3285 app.set_mode(AppMode::Plan);
3286
3287 assert!(!app.cycle_approval_posture());
3288 assert_eq!(app.approval_mode, ApprovalMode::Suggest);
3289 assert_eq!(app.mode_prefs.agent_approval_mode, ApprovalMode::Auto);
3290 assert!(!tmp.path().join("settings.toml").exists());
3291 assert!(
3292 app.status_toasts
3293 .iter()
3294 .any(|toast| toast.text.contains("Read Only"))
3295 );
3296
3297 app.set_mode(AppMode::Operate);
3298 assert_eq!(app.approval_mode, ApprovalMode::Auto);
3299 }
3300
3301 #[test]
3302 fn busy_permission_cycle_changes_neither_runtime_nor_persistence() {
3303 let _env_lock = lock_test_env();
3304 let tmp = tempfile::tempdir().expect("tempdir");
3305 let config_path = tmp.path().join("config.toml");
3306 let _config_env = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
3307 let mut options = test_options(false);
3308 options.config_path = Some(config_path);
3309 let mut app = App::new(options, &Config::default());
3310 let before = app.approval_mode;
3311 app.is_loading = true;
3312
3313 assert!(!app.cycle_approval_posture());
3314 assert_eq!(app.approval_mode, before);
3315 assert_eq!(app.mode_prefs.agent_approval_mode, before);
3316 assert!(!tmp.path().join("settings.toml").exists());
3317 assert!(
3318 app.status_message
3319 .as_deref()
3320 .is_some_and(|message| message.contains("locked"))
3321 );
3322 }
3323
3324 #[test]
3325 fn permission_postures_persist_across_restart() {
3326 let _env_lock = lock_test_env();
3327 for (cycles, expected) in [
3328 (1, ApprovalMode::Auto),
3329 (2, ApprovalMode::Bypass),
3330 (3, ApprovalMode::Suggest),
3331 ] {
3332 let tmp = tempfile::tempdir().expect("tempdir");
3333 let path = tmp.path().join("config.toml");
3334 let config_env = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &path);
3335 let mut options = test_options(false);
3336 options.start_in_agent_mode = true;
3337 options.config_path = Some(path.clone());
3338 let mut app = App::new(options.clone(), &Config::default());
3339 for _ in 0..cycles {
3340 assert!(app.cycle_approval_posture());
3341 }
3342 assert_eq!(app.approval_mode, expected);
3343 assert_eq!(app.trust_mode, expected == ApprovalMode::Bypass);
3344
3345 let restarted = App::new(options, &Config::default());
3346 assert_eq!(restarted.approval_mode, expected);
3347 assert_eq!(restarted.mode_prefs.agent_approval_mode, expected);
3348 assert_eq!(restarted.trust_mode, expected == ApprovalMode::Bypass);
3349 drop(config_env);
3350 }
3351 }
3352
3353 #[test]
3354 fn shift_tab_migrates_user_root_policy_to_durable_tui_posture() {
3355 let _env_lock = lock_test_env();
3356 let tmp = tempfile::tempdir().expect("tempdir");
3357 let config_path = tmp.path().join("config.toml");
3358 let settings_path = tmp.path().join("settings.toml");
3359 std::fs::write(&config_path, "# keep\napproval_policy = \"on-request\"\n")
3360 .expect("root config");
3361 std::fs::write(&settings_path, "permission_posture = \"full-access\"\n").expect("settings");
3362 let _config_env = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
3363 let _approval_env = EnvVarGuard::remove("DEEPSEEK_APPROVAL_POLICY");
3364 let config = Config::load(Some(config_path.clone()), None).expect("load config");
3365 let mut options = test_options(false);
3366 options.start_in_agent_mode = true;
3367 options.config_path = Some(config_path.clone());
3368
3369 let mut app = App::new(options.clone(), &config);
3370 assert_eq!(app.approval_mode, ApprovalMode::Suggest);
3371 assert!(app.approval_policy_locked());
3372
3373 assert!(app.cycle_root_approval_posture());
3374 assert_eq!(app.approval_mode, ApprovalMode::Auto);
3375 assert!(!app.approval_policy_locked());
3376 let saved_config = std::fs::read_to_string(&config_path).expect("saved config");
3377 assert!(saved_config.contains("# keep"));
3378 assert!(!saved_config.contains("approval_policy"));
3379 let saved_settings = std::fs::read_to_string(&settings_path).expect("saved settings");
3380 assert!(saved_settings.contains("permission_posture = \"auto-review\""));
3381
3382 let restarted_config = Config::load(Some(config_path), None).expect("reload config");
3383 let restarted = App::new(options, &restarted_config);
3384 assert_eq!(restarted.approval_mode, ApprovalMode::Auto);
3385 assert!(!restarted.approval_policy_locked());
3386 }
3387
3388 #[test]
3389 fn legacy_yolo_migrates_root_policy_to_agent_full_access() {
3390 let _env_lock = lock_test_env();
3391 let tmp = tempfile::tempdir().expect("tempdir");
3392 let config_path = tmp.path().join("config.toml");
3393 let settings_path = tmp.path().join("settings.toml");
3394 let workspace = tmp.path().join("workspace");
3395 std::fs::create_dir_all(&workspace).expect("workspace");
3396 std::fs::write(&config_path, "# keep\napproval_policy = \"on-request\"\n")
3397 .expect("legacy config");
3398 std::fs::write(&settings_path, "default_mode = \"yolo\"\n").expect("legacy settings");
3399 let _config_env = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
3400 let _approval_env = EnvVarGuard::remove("DEEPSEEK_APPROVAL_POLICY");
3401 let config = Config::load(Some(config_path.clone()), None).expect("load config");
3402 let mut options = test_options(false);
3403 options.start_in_agent_mode = false;
3404 options.workspace = workspace;
3405 options.config_path = Some(config_path.clone());
3406
3407 let app = App::new(options.clone(), &config);
3408
3409 assert_eq!(app.mode, AppMode::Agent);
3410 assert_eq!(app.approval_mode, ApprovalMode::Bypass);
3411 assert!(!app.approval_policy_locked());
3412 let saved_config = std::fs::read_to_string(&config_path).expect("saved config");
3413 assert!(saved_config.contains("# keep"));
3414 assert!(!saved_config.contains("approval_policy"));
3415 let saved_settings = std::fs::read_to_string(&settings_path).expect("saved settings");
3416 assert!(saved_settings.contains("default_mode = \"agent\""));
3417 assert!(saved_settings.contains("permission_posture = \"full-access\""));
3418
3419 let restarted_config = Config::load(Some(config_path), None).expect("reload config");
3420 let restarted = App::new(options, &restarted_config);
3421 assert_eq!(restarted.mode, AppMode::Agent);
3422 assert_eq!(restarted.approval_mode, ApprovalMode::Bypass);
3423 assert!(!restarted.approval_policy_locked());
3424 }
3425
3426 #[test]
3427 fn legacy_yolo_honors_a_missing_explicit_config_path_without_home_fallback() {
3428 let _env_lock = lock_test_env();
3429 let tmp = tempfile::tempdir().expect("tempdir");
3430 let home = tmp.path().join("home");
3431 let home_config_dir = home.join(codewhale_config::CODEWHALE_APP_DIR);
3432 let override_dir = tmp.path().join("missing-override");
3433 let missing_override = override_dir.join("config.toml");
3434 let workspace = tmp.path().join("workspace");
3435 std::fs::create_dir_all(&home_config_dir).expect("home config dir");
3436 std::fs::create_dir_all(&override_dir).expect("override dir");
3437 std::fs::create_dir_all(&workspace).expect("workspace");
3438 let home_config = home_config_dir.join("config.toml");
3439 std::fs::write(
3440 &home_config,
3441 "# actual fallback\napproval_policy = \"on-request\"\n",
3442 )
3443 .expect("home config");
3444 let override_settings = override_dir.join("settings.toml");
3445 std::fs::write(&override_settings, "default_mode = \"yolo\"\n").expect("legacy settings");
3446
3447 let _home = EnvVarGuard::set("HOME", &home);
3448 let _user_profile = EnvVarGuard::set("USERPROFILE", &home);
3449 let _codewhale_home = EnvVarGuard::remove("CODEWHALE_HOME");
3450 let _codewhale_config = EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
3451 let _deepseek_config = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &missing_override);
3452 let _approval_env = EnvVarGuard::remove("DEEPSEEK_APPROVAL_POLICY");
3453
3454 let config = Config::load(None, None).expect("load explicit missing config");
3455 assert_eq!(config.approval_policy, None);
3456 let mut options = test_options(false);
3457 options.start_in_agent_mode = false;
3458 options.workspace = workspace;
3459 options.config_path = None;
3460
3461 let app = App::new(options, &config);
3462
3463 assert_eq!(app.mode, AppMode::Agent);
3464 assert_eq!(app.approval_mode, ApprovalMode::Bypass);
3465 assert!(!app.approval_policy_locked());
3466 assert!(
3467 !missing_override.exists(),
3468 "settings migration must not create an unrelated config document"
3469 );
3470 let saved_home_config = std::fs::read_to_string(&home_config).expect("untouched home config");
3471 assert!(saved_home_config.contains("# actual fallback"));
3472 assert!(saved_home_config.contains("approval_policy = \"on-request\""));
3473 let saved_settings =
3474 std::fs::read_to_string(&override_settings).expect("normalized override settings");
3475 assert!(saved_settings.contains("default_mode = \"agent\""));
3476 assert!(saved_settings.contains("permission_posture = \"full-access\""));
3477 }
3478
3479 #[test]
3480 fn managed_requirements_ignore_saved_full_access_and_lock_changes() {
3481 let _env_lock = lock_test_env();
3482 let tmp = tempfile::tempdir().expect("tempdir");
3483 let config_path = tmp.path().join("config.toml");
3484 let requirements_path = tmp.path().join("requirements.toml");
3485 std::fs::write(
3486 tmp.path().join("settings.toml"),
3487 "permission_posture = \"full-access\"\n",
3488 )
3489 .expect("settings");
3490 std::fs::write(
3491 &requirements_path,
3492 "allowed_approval_policies = [\"on-request\"]\n",
3493 )
3494 .expect("requirements");
3495 let _config_env = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
3496 let config = Config {
3497 requirements_path: Some(requirements_path.to_string_lossy().into_owned()),
3498 ..Config::default()
3499 };
3500
3501 let mut app = App::new(test_options(false), &config);
3502
3503 assert!(app.approval_policy_locked());
3504 assert!(app.approval_policy_requirements_managed());
3505 assert_eq!(app.approval_mode, ApprovalMode::Suggest);
3506 assert!(!app.cycle_approval_posture());
3507 assert_eq!(app.approval_mode, ApprovalMode::Suggest);
3508 assert!(
3509 app.status_toasts
3510 .iter()
3511 .any(|toast| toast.text.contains("controlled"))
3512 );
3513 }
3514
3515 #[test]
3516 fn set_mode_agent_to_yolo_to_agent_restores_baseline_without_yolo_leak() {
3517 // Round-trip Agent -> YOLO -> Agent must not leave YOLO's elevated authority
3518 // (shell/trust/Auto) bleeding into the restored Agent surface (#3386).
3519 let mut options = test_options(false);
3520 options.allow_shell = false;
3521 options.start_in_agent_mode = true;
3522 let mut app = App::new(options, &Config::default());
3523 // User's chosen Agent surface: shell on, trust off, Suggest approvals.
3524 app.allow_shell = true;
3525 app.trust_mode = false;
3526 app.approval_mode = ApprovalMode::Suggest;
3527 app.yolo_compat_notified = true;
3528
3529 app.set_mode(AppMode::Yolo);
3530 assert!(app.allow_shell);
3531 assert!(app.trust_mode);
3532 assert_eq!(app.approval_mode, ApprovalMode::Bypass);
3533 assert!(app.yolo);
3534
3535 app.set_mode(AppMode::Agent);
3536 assert_eq!(app.mode, AppMode::Agent);
3537 assert!(app.allow_shell, "shell baseline preserved");
3538 assert!(
3539 !app.trust_mode,
3540 "YOLO trust authority must not leak into Agent"
3541 );
3542 assert_eq!(
3543 app.approval_mode,
3544 ApprovalMode::Suggest,
3545 "YOLO Auto approvals must not leak into Agent"
3546 );
3547 assert!(!app.yolo);
3548 }
3549
3550 #[test]
3551 fn set_mode_plan_to_yolo_to_agent_does_not_bleed_yolo_into_agent() {
3552 // Plan -> YOLO -> Agent: the Agent baseline captured before leaving Agent is
3553 // what we land on, untouched by the transient Plan or YOLO policies (#3386).
3554 let mut options = test_options(false);
3555 options.allow_shell = false;
3556 options.start_in_agent_mode = true;
3557 let mut app = App::new(options, &Config::default());
3558 app.allow_shell = false;
3559 app.trust_mode = false;
3560 app.approval_mode = ApprovalMode::Never;
3561 app.yolo_compat_notified = true;
3562
3563 app.set_mode(AppMode::Plan);
3564 // Plan is read-only regardless of the baseline.
3565 assert!(!app.allow_shell);
3566 assert!(!app.trust_mode);
3567 assert_eq!(app.approval_mode, ApprovalMode::Suggest);
3568
3569 app.set_mode(AppMode::Yolo);
3570 assert!(app.allow_shell);
3571 assert!(app.trust_mode);
3572 assert_eq!(app.approval_mode, ApprovalMode::Bypass);
3573
3574 app.set_mode(AppMode::Agent);
3575 assert_eq!(app.mode, AppMode::Agent);
3576 assert!(!app.allow_shell);
3577 assert!(!app.trust_mode);
3578 assert_eq!(app.approval_mode, ApprovalMode::Never);
3579 }
3580
3581 #[test]
3582 fn set_mode_captures_agent_edits_as_the_durable_baseline() {
3583 // Editing the permission surface in Agent updates the baseline that a later
3584 // Plan -> Agent (or YOLO -> Agent) restores to (#3386).
3585 let mut options = test_options(false);
3586 options.allow_shell = false;
3587 options.start_in_agent_mode = true;
3588 let mut app = App::new(options, &Config::default());
3589 assert_eq!(app.mode, AppMode::Agent);
3590 app.allow_shell = false;
3591 app.set_agent_approval_posture(ApprovalMode::Suggest);
3592
3593 // Initial baseline restores to no-shell / Suggest.
3594 app.set_mode(AppMode::Plan);
3595 app.set_mode(AppMode::Agent);
3596 assert!(!app.allow_shell);
3597 assert_eq!(app.approval_mode, ApprovalMode::Suggest);
3598
3599 // User now turns shell on and tightens approvals while in Agent.
3600 app.allow_shell = true;
3601 app.approval_mode = ApprovalMode::Never;
3602
3603 // A Plan hop and back must restore the *edited* baseline, not the original.
3604 app.set_mode(AppMode::Plan);
3605 assert!(!app.allow_shell, "Plan is read-only");
3606 app.set_mode(AppMode::Agent);
3607 assert!(app.allow_shell, "edited shell baseline restored");
3608 assert_eq!(app.approval_mode, ApprovalMode::Never);
3609 }
3610
3611 #[test]
3612 fn yolo_start_with_default_config_restores_interactive_agent_shell_baseline() {
3613 // Isolate from the developer's live settings.toml — a saved
3614 // `permission_posture` (e.g. full-access) must not leak into the
3615 // durable baseline these assertions depend on.
3616 let _env_lock = lock_test_env();
3617 let tmp = tempfile::tempdir().expect("tempdir");
3618 let config_path = tmp.path().join("config.toml");
3619 let _config_env = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
3620 let mut options = test_options(true);
3621 options.config_path = Some(config_path);
3622 let mut app = App::new(options, &Config::default());
3623 // --yolo starts in Agent mode with the full-access compat shim (M6).
3624 assert_eq!(app.mode, AppMode::Agent);
3625 assert!(app.yolo);
3626 assert!(app.allow_shell);
3627 assert!(app.trust_mode);
3628 assert_eq!(app.approval_mode, ApprovalMode::Bypass);
3629
3630 app.set_mode(AppMode::Agent);
3631 assert!(
3632 app.allow_shell,
3633 "default interactive Agent baseline should expose approval-gated shell after YOLO downshift"
3634 );
3635 assert!(!app.trust_mode);
3636 assert_eq!(app.approval_mode, ApprovalMode::Suggest);
3637 }
3638
3639 #[test]
3640 fn leaving_yolo_after_startup_restores_baseline_policies() {
3641 // Isolate from the developer's live settings.toml — a saved
3642 // `permission_posture` (e.g. full-access) must not leak into the
3643 // durable baseline these assertions depend on.
3644 let _env_lock = lock_test_env();
3645 let tmp = tempfile::tempdir().expect("tempdir");
3646 let config_path = tmp.path().join("config.toml");
3647 let _config_env = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
3648 let config = Config {
3649 allow_shell: Some(false),
3650 ..Default::default()
3651 };
3652
3653 let mut options = test_options(true);
3654 options.config_path = Some(config_path);
3655 let mut app = App::new(options, &config);
3656 // --yolo starts in Agent mode with the full-access compat shim (M6).
3657 assert_eq!(app.mode, AppMode::Agent);
3658 assert!(app.yolo);
3659 assert!(app.allow_shell);
3660 assert!(app.trust_mode);
3661 assert_eq!(app.approval_mode, ApprovalMode::Bypass);
3662
3663 app.set_mode(AppMode::Agent);
3664 assert!(!app.allow_shell);
3665 assert!(!app.trust_mode);
3666 assert_eq!(app.approval_mode, ApprovalMode::Suggest);
3667 }
3668
3669 #[test]
3670 fn configured_approval_policy_initializes_live_approval_mode() {
3671 let config = Config {
3672 approval_policy: Some("never".to_string()),
3673 ..Default::default()
3674 };
3675 let mut options = test_options(false);
3676 options.start_in_agent_mode = true;
3677
3678 let app = App::new(options, &config);
3679
3680 assert_eq!(app.mode, AppMode::Agent);
3681 assert_eq!(app.approval_mode, ApprovalMode::Never);
3682 }
3683
3684 #[test]
3685 fn test_mark_history_updated() {
3686 let mut app = App::new(test_options(false), &Config::default());
3687 let initial_version = app.history_version;
3688 app.mark_history_updated();
3689 assert!(app.history_version > initial_version);
3690 }
3691
3692 #[test]
3693 fn live_motion_invalidation_only_bumps_live_transcript_rows() {
3694 let mut app = App::new(test_options(false), &Config::default());
3695 app.history = vec![
3696 HistoryCell::Assistant {
3697 content: "settled".to_string(),
3698 streaming: false,
3699 },
3700 HistoryCell::Assistant {
3701 content: "streaming".to_string(),
3702 streaming: true,
3703 },
3704 HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
3705 name: "read_file".to_string(),
3706 status: ToolStatus::Running,
3707 input_summary: None,
3708 output: None,
3709 prompts: None,
3710 spillover_path: None,
3711 output_summary: None,
3712 is_diff: false,
3713 })),
3714 HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
3715 name: "agent".to_string(),
3716 status: ToolStatus::Running,
3717 input_summary: Some("action: spawn".to_string()),
3718 output: None,
3719 prompts: None,
3720 spillover_path: None,
3721 output_summary: None,
3722 is_diff: false,
3723 })),
3724 HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
3725 name: "read_file".to_string(),
3726 status: ToolStatus::Success,
3727 input_summary: None,
3728 output: Some("done".to_string()),
3729 prompts: None,
3730 spillover_path: None,
3731 output_summary: None,
3732 is_diff: false,
3733 })),
3734 ];
3735 app.resync_history_revisions();
3736 let history_before = app.history_revisions.clone();
3737
3738 let active = app.active_cell.get_or_insert_with(ActiveCell::new);
3739 active.push_untracked(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
3740 name: "web_search".to_string(),
3741 status: ToolStatus::Running,
3742 input_summary: None,
3743 output: None,
3744 prompts: None,
3745 spillover_path: None,
3746 output_summary: None,
3747 is_diff: false,
3748 })));
3749 let app_active_before = app.active_cell_revision;
3750 let cell_active_before = app.active_cell.as_ref().expect("active cell").revision();
3751
3752 app.mark_live_motion_updated();
3753
3754 assert_eq!(app.history_revisions[0], history_before[0]);
3755 assert_ne!(app.history_revisions[1], history_before[1]);
3756 assert_ne!(app.history_revisions[2], history_before[2]);
3757 assert_eq!(app.history_revisions[3], history_before[3]);
3758 assert_eq!(app.history_revisions[4], history_before[4]);
3759 assert_ne!(app.active_cell_revision, app_active_before);
3760 assert_ne!(
3761 app.active_cell.as_ref().expect("active cell").revision(),
3762 cell_active_before
3763 );
3764
3765 let history_after_all_live = app.history_revisions.clone();
3766 let app_active_after_all_live = app.active_cell_revision;
3767 let cell_active_after_all_live = app.active_cell.as_ref().expect("active cell").revision();
3768 app.mark_live_history_motion_updated();
3769
3770 assert_eq!(app.history_revisions[0], history_after_all_live[0]);
3771 assert_ne!(app.history_revisions[1], history_after_all_live[1]);
3772 assert_ne!(app.history_revisions[2], history_after_all_live[2]);
3773 assert_eq!(app.history_revisions[3], history_after_all_live[3]);
3774 assert_eq!(app.history_revisions[4], history_after_all_live[4]);
3775 assert_eq!(app.active_cell_revision, app_active_after_all_live);
3776 assert_eq!(
3777 app.active_cell.as_ref().expect("active cell").revision(),
3778 cell_active_after_all_live
3779 );
3780 }
3781
3782 #[test]
3783 fn expanded_tool_runs_rebase_when_history_prefix_shifts() {
3784 let mut app = App::new(test_options(false), &Config::default());
3785 app.expanded_tool_runs = std::collections::HashSet::from([2usize, 6usize]);
3786
3787 app.shift_history_maps_down(3);
3788
3789 assert_eq!(app.expanded_tool_runs, std::collections::HashSet::from([3]));
3790 }
3791
3792 #[test]
3793 fn expanded_tool_runs_prune_when_history_is_truncated() {
3794 let mut app = App::new(test_options(false), &Config::default());
3795 for idx in 0..5 {
3796 app.add_message(HistoryCell::System {
3797 content: format!("cell {idx}"),
3798 });
3799 }
3800 app.expanded_tool_runs = std::collections::HashSet::from([1usize, 4usize]);
3801
3802 app.truncate_history_to(3);
3803
3804 assert_eq!(app.expanded_tool_runs, std::collections::HashSet::from([1]));
3805 }
3806
3807 #[test]
3808 fn tool_run_expansion_toggle_opens_and_closes_run() {
3809 let mut app = App::new(test_options(false), &Config::default());
3810 app.tool_collapse_mode = ToolCollapseMode::Compact;
3811 app.tool_collapse_threshold = 3;
3812 for name in ["read_file", "list_dir", "web_search"] {
3813 app.add_message(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
3814 name: name.to_string(),
3815 status: ToolStatus::Success,
3816 input_summary: None,
3817 output: Some("ok".to_string()),
3818 prompts: None,
3819 spillover_path: None,
3820 output_summary: None,
3821 is_diff: false,
3822 })));
3823 }
3824
3825 assert!(app.toggle_tool_run_expansion_at(0));
3826 assert!(app.expanded_tool_runs.contains(&0));
3827 assert!(app.toggle_tool_run_expansion_at(2));
3828 assert!(!app.expanded_tool_runs.contains(&0));
3829 assert!(!app.toggle_tool_run_expansion_at(99));
3830 }
3831
3832 #[test]
3833 fn tool_run_expansion_toggle_handles_active_run() {
3834 let mut app = App::new(test_options(false), &Config::default());
3835 app.tool_collapse_mode = ToolCollapseMode::Compact;
3836 app.tool_collapse_threshold = 3;
3837 app.add_message(HistoryCell::User {
3838 content: "go".to_string(),
3839 });
3840
3841 let active_start = app.history.len();
3842 let active = app.active_cell.get_or_insert_with(ActiveCell::new);
3843 for name in ["read_file", "list_dir", "web_search"] {
3844 active.push_untracked(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
3845 name: name.to_string(),
3846 status: ToolStatus::Success,
3847 input_summary: None,
3848 output: Some("ok".to_string()),
3849 prompts: None,
3850 spillover_path: None,
3851 output_summary: None,
3852 is_diff: false,
3853 })));
3854 }
3855
3856 assert!(app.toggle_tool_run_expansion_at(active_start));
3857 assert!(app.expanded_tool_runs.contains(&active_start));
3858 assert!(app.toggle_tool_run_expansion_at(active_start + 2));
3859 assert!(!app.expanded_tool_runs.contains(&active_start));
3860 }
3861
3862 #[test]
3863 fn test_scroll_operations() {
3864 let mut app = App::new(test_options(false), &Config::default());
3865 // Just verify scroll methods can be called without panic
3866 app.scroll_up(5);
3867 app.scroll_down(3);
3868 }
3869
3870 #[test]
3871 fn resize_preserves_scrolled_transcript_position() {
3872 let mut app = App::new(test_options(false), &Config::default());
3873 app.viewport.transcript_scroll = TranscriptScroll::at_line(42);
3874 app.viewport.last_transcript_top = 42;
3875 app.viewport.pending_scroll_delta = 5;
3876
3877 app.handle_resize(120, 40);
3878
3879 let meta = vec![
3880 TranscriptLineMeta::Spacer {
3881 copy_prefix_width: 0
3882 };
3883 240
3884 ];
3885 let (_, top) = app.viewport.transcript_scroll.resolve_top(&meta, 200);
3886 assert_eq!(top, 42);
3887 assert_eq!(app.viewport.pending_scroll_delta, 0);
3888 }
3889
3890 #[test]
3891 fn resize_keeps_tail_state_when_user_was_at_tail() {
3892 let mut app = App::new(test_options(false), &Config::default());
3893 app.viewport.transcript_scroll = TranscriptScroll::to_bottom();
3894 app.viewport.last_transcript_top = 42;
3895
3896 app.handle_resize(120, 40);
3897
3898 assert!(app.viewport.transcript_scroll.is_at_tail());
3899 }
3900
3901 #[test]
3902 fn resize_seeds_visible_height_for_paging_before_next_render() {
3903 let mut app = App::new(test_options(false), &Config::default());
3904 app.viewport.last_transcript_visible = 12;
3905
3906 app.handle_resize(120, 40);
3907 assert_eq!(app.viewport.last_transcript_visible, 38);
3908
3909 app.handle_resize(120, 1);
3910 assert_eq!(app.viewport.last_transcript_visible, 1);
3911 }
3912
3913 #[test]
3914 fn test_add_message() {
3915 let mut app = App::new(test_options(false), &Config::default());
3916 let initial_len = app.history.len();
3917 app.add_message(HistoryCell::User {
3918 content: "test".to_string(),
3919 });
3920 assert_eq!(app.history.len(), initial_len + 1);
3921 }
3922
3923 #[test]
3924 fn test_compaction_config() {
3925 let mut app = App::new(test_options(false), &Config::default());
3926 let config = app.compaction_config();
3927 // Config should be valid (just checking it returns something)
3928 let _ = config.enabled;
3929
3930 app.auto_model = true;
3931 app.model = "auto".to_string();
3932 app.last_effective_model = None;
3933 let config = app.compaction_config();
3934 assert_eq!(config.model, DEFAULT_TEXT_MODEL);
3935
3936 app.last_effective_model = Some("deepseek-v4-flash".to_string());
3937 let config = app.compaction_config();
3938 assert_eq!(config.model, "deepseek-v4-flash");
3939 }
3940
3941 #[test]
3942 fn test_update_model_compaction_budget() {
3943 let mut app = App::new(test_options(false), &Config::default());
3944 // Pin the inputs so the budget math is deterministic and does not
3945 // depend on the developer's local `auto_compact_threshold_percent`
3946 // setting (App::new loads real settings) or on auto-model resolution.
3947 app.auto_model = false;
3948 app.api_provider = ApiProvider::Deepseek;
3949 app.active_route_limits = None;
3950 app.active_context_window_override = None;
3951 app.auto_compact_threshold_percent = 80.0;
3952
3953 // A large-context model earns a proportionally larger compaction
3954 // budget; an unknown model falls back to the fixed default threshold.
3955 app.model = "deepseek-v4-pro".to_string();
3956 app.update_model_compaction_budget();
3957 let large_window_threshold = app.compact_threshold;
3958
3959 app.model = "unknown-test-model".to_string();
3960 app.update_model_compaction_budget();
3961 let unknown_threshold = app.compact_threshold;
3962
3963 assert!(
3964 unknown_threshold > 0,
3965 "unknown model must still get a positive budget"
3966 );
3967 assert!(
3968 large_window_threshold > unknown_threshold,
3969 "a large-context model ({large_window_threshold}) should budget more \
3970 than an unknown model ({unknown_threshold})"
3971 );
3972 }
3973
3974 #[test]
3975 fn test_input_history_navigation() {
3976 let mut app = App::new(test_options(false), &Config::default());
3977 app.input_history.push("first".to_string());
3978 app.input_history.push("second".to_string());
3979
3980 // Navigate up
3981 app.history_up();
3982 assert!(app.history_index.is_some());
3983
3984 // Navigate down
3985 app.history_down();
3986 }
3987
3988 #[test]
3989 fn input_history_down_restores_live_draft_after_accidental_up() {
3990 let mut app = App::new(test_options(false), &Config::default());
3991 app.input_history.push("previous prompt".to_string());
3992 app.input = "careful current draft".to_string();
3993 app.cursor_position = "careful".chars().count();
3994
3995 app.history_up();
3996 assert_eq!(app.input, "previous prompt");
3997
3998 app.history_down();
3999 assert_eq!(app.input, "careful current draft");
4000 assert_eq!(app.cursor_position, "careful".chars().count());
4001 assert!(app.history_index.is_none());
4002 }
4003
4004 #[test]
4005 fn input_history_navigation_clears_stale_selection() {
4006 let mut app = App::new(test_options(false), &Config::default());
4007 app.input_history.push("previous input".to_string());
4008 app.input = "hello world".to_string();
4009 app.cursor_position = "hello ".chars().count();
4010 app.selection_anchor = Some(app.input.chars().count());
4011
4012 app.history_up();
4013 assert_eq!(app.input, "previous input");
4014 assert!(app.selection_anchor.is_none());
4015
4016 app.insert_char('x');
4017 assert_eq!(app.input, "previous inputx");
4018 }
4019
4020 #[test]
4021 fn input_history_restores_empty_draft_at_end_of_navigation() {
4022 let mut app = App::new(test_options(false), &Config::default());
4023 app.input_history.push("previous prompt".to_string());
4024
4025 app.history_up();
4026 assert_eq!(app.input, "previous prompt");
4027
4028 app.history_down();
4029 assert!(app.input.is_empty());
4030 assert_eq!(app.cursor_position, 0);
4031 assert!(app.history_index.is_none());
4032 }
4033
4034 #[test]
4035 fn word_cursor_helpers_move_by_whitespace_delimited_words() {
4036 let mut app = App::new(test_options(false), &Config::default());
4037 app.input = "alpha beta gamma".to_string();
4038 app.cursor_position = 0;
4039
4040 app.move_cursor_word_forward();
4041 assert_eq!(app.cursor_position, "alpha ".chars().count());
4042
4043 app.move_cursor_word_forward();
4044 assert_eq!(app.cursor_position, "alpha beta ".chars().count());
4045
4046 app.move_cursor_word_backward();
4047 assert_eq!(app.cursor_position, "alpha ".chars().count());
4048 }
4049
4050 #[test]
4051 fn editing_history_entry_leaves_navigation_mode() {
4052 let mut app = App::new(test_options(false), &Config::default());
4053 app.input_history.push("previous prompt".to_string());
4054 app.input = "current draft".to_string();
4055 app.cursor_position = app.input.chars().count();
4056
4057 app.history_up();
4058 app.insert_char('!');
4059 app.history_down();
4060
4061 assert_eq!(app.input, "previous prompt!");
4062 assert!(app.history_index.is_none());
4063 }
4064
4065 #[test]
4066 fn history_search_filters_matches_and_skips_duplicates() {
4067 let mut app = App::new(test_options(false), &Config::default());
4068 app.input_history.clear();
4069 app.input_history.push("alpha one".to_string());
4070 app.input_history.push("beta two".to_string());
4071 app.input_history.push("alpha one".to_string());
4072 app.draft_history.push_back("draft alpha".to_string());
4073
4074 app.start_history_search();
4075 app.history_search_insert_str("alpha");
4076
4077 assert_eq!(
4078 app.history_search_matches(),
4079 vec!["draft alpha".to_string(), "alpha one".to_string()]
4080 );
4081 }
4082
4083 #[test]
4084 fn history_search_matches_unicode_case_insensitively() {
4085 let mut app = App::new(test_options(false), &Config::default());
4086 app.input_history.clear();
4087 app.input_history.push("CAFÉ prompt".to_string());
4088
4089 app.start_history_search();
4090 app.history_search_insert_str("café");
4091
4092 assert_eq!(
4093 app.history_search_matches(),
4094 vec!["CAFÉ prompt".to_string()]
4095 );
4096 }
4097
4098 #[test]
4099 fn history_search_accepts_match_without_submitting() {
4100 let mut app = App::new(test_options(false), &Config::default());
4101 app.input_history.clear();
4102 app.input_history.push("older prompt".to_string());
4103
4104 app.start_history_search();
4105 app.history_search_insert_str("older");
4106
4107 assert!(app.accept_history_search());
4108 assert_eq!(app.input, "older prompt");
4109 assert_eq!(app.cursor_position, "older prompt".chars().count());
4110 assert!(app.composer_history_search.is_none());
4111 }
4112
4113 #[test]
4114 fn history_search_cancel_restores_pre_search_draft() {
4115 let mut app = App::new(test_options(false), &Config::default());
4116 app.input_history.clear();
4117 app.input = "current draft".to_string();
4118 app.cursor_position = 7;
4119 app.input_history.push("older prompt".to_string());
4120
4121 app.start_history_search();
4122 app.history_search_insert_str("older");
4123 app.cancel_history_search();
4124
4125 assert_eq!(app.input, "current draft");
4126 assert_eq!(app.cursor_position, 7);
4127 assert!(app.composer_history_search.is_none());
4128 }
4129
4130 #[test]
4131 fn recoverable_clear_stashes_nonempty_draft() {
4132 let mut app = App::new(test_options(false), &Config::default());
4133 app.input_history.clear();
4134 app.input = "recover this".to_string();
4135 app.cursor_position = app.input.chars().count();
4136
4137 app.clear_input_recoverable();
4138 app.start_history_search();
4139 app.history_search_insert_str("recover");
4140
4141 assert_eq!(
4142 app.history_search_matches(),
4143 vec!["recover this".to_string()]
4144 );
4145 }
4146
4147 #[test]
4148 fn clear_undo_buffer_is_set_on_clear_input_recoverable() {
4149 let mut app = App::new(test_options(false), &Config::default());
4150 app.input = "hello".to_string();
4151 app.cursor_position = 5;
4152
4153 app.clear_input_recoverable();
4154
4155 assert!(app.input.is_empty());
4156 assert_eq!(app.clear_undo_buffer.as_deref(), Some("hello"));
4157 }
4158
4159 #[test]
4160 fn clear_undo_buffer_is_none_when_clearing_empty_input() {
4161 let mut app = App::new(test_options(false), &Config::default());
4162 assert!(app.input.is_empty());
4163
4164 app.clear_input_recoverable();
4165
4166 assert!(app.clear_undo_buffer.is_none());
4167 }
4168
4169 #[test]
4170 fn restore_last_cleared_input_restores_saved_draft() {
4171 let mut app = App::new(test_options(false), &Config::default());
4172 app.input = "previous".to_string();
4173 app.cursor_position = 8;
4174 app.clear_input_recoverable();
4175 assert!(app.input.is_empty());
4176
4177 let restored = app.restore_last_cleared_input_if_empty();
4178 assert!(restored);
4179 assert_eq!(app.input, "previous");
4180 assert!(app.clear_undo_buffer.is_none());
4181 }
4182
4183 #[test]
4184 fn restore_last_cleared_input_does_nothing_when_composer_not_empty() {
4185 let mut app = App::new(test_options(false), &Config::default());
4186 app.clear_undo_buffer = Some("old".to_string());
4187 app.input = "current".to_string();
4188 assert!(!app.restore_last_cleared_input_if_empty());
4189 }
4190
4191 #[test]
4192 fn composer_paste_flushes_pending_burst_and_normalizes_crlf() {
4193 let mut app = App::new(test_options(false), &Config::default());
4194 app.use_paste_burst_detection = true;
4195 let now = Instant::now();
4196 let key = crossterm::event::KeyEvent::new(
4197 crossterm::event::KeyCode::Char('x'),
4198 crossterm::event::KeyModifiers::NONE,
4199 );
4200
4201 assert!(crate::tui::paste::handle_paste_burst_key(
4202 &mut app, &key, now
4203 ));
4204 assert!(
4205 app.input.is_empty(),
4206 "first burst char should stay buffered"
4207 );
4208
4209 app.insert_paste_text("a\r\nb\rc");
4210
4211 assert_eq!(app.input, "xa\nb\nc");
4212 assert_eq!(app.cursor_position, "xa\nb\nc".chars().count());
4213 assert!(!app.paste_burst.is_active());
4214 }
4215
4216 #[test]
4217 fn bracketed_paste_preserves_bare_carriage_return_line_breaks() {
4218 let mut app = App::new(test_options(false), &Config::default());
4219
4220 app.insert_paste_text("alpha\r indented\r# literal heading\r- literal list");
4221
4222 assert_eq!(
4223 app.input,
4224 "alpha\n indented\n# literal heading\n- literal list"
4225 );
4226 assert_eq!(app.cursor_position, app.input.chars().count());
4227 }
4228
4229 #[test]
4230 fn enter_during_active_paste_burst_appends_newline_to_buffer_not_submit() {
4231 // #1073: when chars are still being assembled into a paste burst and
4232 // an Enter arrives (the trailing newline of the paste), the Enter
4233 // must be absorbed into the burst buffer — not fired as a submit.
4234 let mut app = App::new(test_options(false), &Config::default());
4235 app.use_paste_burst_detection = true;
4236 let now = Instant::now();
4237 app.paste_burst.append_char_to_buffer('h', now);
4238 app.paste_burst.append_char_to_buffer('i', now);
4239 assert!(app.paste_burst.is_active());
4240 assert!(app.input.is_empty());
4241
4242 let result = app.handle_composer_enter();
4243
4244 assert!(
4245 result.is_none(),
4246 "Enter during active paste burst must not submit"
4247 );
4248 let flushed = app.paste_burst.flush_before_modified_input();
4249 assert_eq!(
4250 flushed.as_deref(),
4251 Some("hi\n"),
4252 "newline must land in the burst buffer so the next flush carries it"
4253 );
4254 }
4255
4256 #[test]
4257 fn enter_inside_paste_burst_window_after_flush_inserts_newline_not_submit() {
4258 // #1073: after a burst has flushed (text now in `input`), the
4259 // suppression window stays open for ~120ms. An Enter arriving in
4260 // that window is the trailing newline of the paste, not a user
4261 // submit — insert it as a literal newline into the composer.
4262 let mut app = App::new(test_options(false), &Config::default());
4263 app.use_paste_burst_detection = true;
4264 app.input = "hello".to_string();
4265 app.cursor_position = "hello".chars().count();
4266 let now = Instant::now();
4267 app.paste_burst.extend_window(now);
4268 assert!(!app.paste_burst.is_active());
4269 assert!(
4270 app.paste_burst.newline_should_insert_instead_of_submit(now),
4271 "suppression window should be open"
4272 );
4273
4274 let result = app.handle_composer_enter();
4275
4276 assert!(
4277 result.is_none(),
4278 "Enter inside post-flush suppression window must not submit"
4279 );
4280 assert_eq!(
4281 app.input, "hello\n",
4282 "newline must be inserted into the composer instead of firing a submit"
4283 );
4284 }
4285
4286 /// The absorbed Enter above must not buy the window more time. Re-arming on
4287 /// it meant a user pressing Enter to send kept extending suppression by
4288 /// another 120ms per press, so the composer only ever grew newlines and
4289 /// never submitted.
4290 #[test]
4291 fn enter_absorbed_after_flush_does_not_re_arm_the_suppression_window() {
4292 let mut app = App::new(test_options(false), &Config::default());
4293 app.use_paste_burst_detection = true;
4294 app.input = "hello".to_string();
4295 app.cursor_position = "hello".chars().count();
4296 let now = Instant::now();
4297 app.paste_burst.extend_window(now);
4298
4299 assert!(
4300 app.handle_composer_enter().is_none(),
4301 "first Enter is absorbed as the paste's possible trailing newline"
4302 );
4303 assert_eq!(app.input, "hello\n");
4304
4305 // The window must still expire relative to `now` — the moment the burst
4306 // last saw real input — not relative to the Enter that was absorbed.
4307 assert!(
4308 !app.paste_burst
4309 .newline_should_insert_instead_of_submit(now + Duration::from_millis(121)),
4310 "absorbing an Enter must not extend the suppression window"
4311 );
4312 }
4313
4314 #[test]
4315 fn enter_outside_any_paste_burst_window_submits_normally() {
4316 // Regression guard: the suppression must not trip when the user
4317 // actually wants to submit.
4318 let mut app = App::new(test_options(false), &Config::default());
4319 app.use_paste_burst_detection = true;
4320 app.input = "hello world".to_string();
4321 app.cursor_position = "hello world".chars().count();
4322
4323 let result = app.handle_composer_enter();
4324
4325 assert_eq!(
4326 result.as_deref(),
4327 Some("hello world"),
4328 "Enter outside any paste burst window must submit normally"
4329 );
4330 assert!(
4331 app.input.is_empty(),
4332 "submit_input should clear the composer"
4333 );
4334 }
4335
4336 #[test]
4337 fn enter_with_paste_burst_detection_disabled_submits_normally() {
4338 // When the user has explicitly turned off paste-burst detection
4339 // (`bracketed_paste = false` is independent, this is the
4340 // `paste_burst_detection` setting), the suppression must be
4341 // skipped — otherwise turning it off would not actually turn it
4342 // off.
4343 let mut app = App::new(test_options(false), &Config::default());
4344 app.use_paste_burst_detection = false;
4345 app.input = "ship it".to_string();
4346 app.cursor_position = "ship it".chars().count();
4347 let now = Instant::now();
4348 app.paste_burst.extend_window(now);
4349
4350 let result = app.handle_composer_enter();
4351
4352 assert_eq!(result.as_deref(), Some("ship it"));
4353 }
4354
4355 #[test]
4356 fn clipboard_text_paste_matches_bracketed_paste_state() {
4357 let text = "alpha\r\nbeta";
4358 let mut bracketed = App::new(test_options(false), &Config::default());
4359 let mut clipboard = App::new(test_options(false), &Config::default());
4360
4361 bracketed.insert_paste_text(text);
4362 clipboard.apply_clipboard_content(ClipboardContent::Text(text.to_string()));
4363
4364 assert_eq!(clipboard.input, bracketed.input);
4365 assert_eq!(clipboard.cursor_position, bracketed.cursor_position);
4366 assert_eq!(clipboard.slash_menu_hidden, bracketed.slash_menu_hidden);
4367 assert_eq!(clipboard.mention_menu_hidden, bracketed.mention_menu_hidden);
4368 }
4369
4370 #[test]
4371 fn ssh_direct_clipboard_paste_points_to_terminal_owned_bracketed_paste() {
4372 let mut app = App::new(test_options(false), &Config::default());
4373 app.input = "keep this draft".to_string();
4374 app.cursor_position = app.input.chars().count();
4375 app.clipboard = ClipboardHandler::for_test(true, true);
4376
4377 assert!(!app.paste_from_clipboard());
4378 assert_eq!(app.input, "keep this draft");
4379 let hint = app
4380 .status_message
4381 .as_deref()
4382 .expect("remote paste hint")
4383 .to_string();
4384 assert!(hint.contains("SSH paste uses your local terminal"));
4385 assert!(hint.contains("Cmd+V on macOS"));
4386 assert!(hint.contains("Ctrl+Shift+V on Linux/Windows"));
4387 }
4388
4389 #[test]
4390 fn clipboard_image_paste_keeps_adjacent_text_and_concise_status() {
4391 let mut app = App::new(test_options(false), &Config::default());
4392 app.input = "before after".to_string();
4393 app.cursor_position = "before".chars().count();
4394
4395 app.apply_clipboard_content(ClipboardContent::Image(PastedImage {
4396 path: PathBuf::from("/tmp/pasted.png"),
4397 width: 8,
4398 height: 4,
4399 byte_len: 2048,
4400 }));
4401
4402 assert!(
4403 app.input
4404 .contains("before\n[Attached image: 8x4 PNG (2KB) at /tmp/pasted.png]")
4405 );
4406 assert!(app.input.contains("] after"));
4407 let status = app.status_message.as_deref().expect("status message");
4408 assert_eq!(status, "Attached image: 8x4 PNG (2KB)");
4409 }
4410
4411 #[test]
4412 fn pasted_text_and_image_placeholders_survive_history_and_queue_paths() {
4413 let mut app = App::new(test_options(false), &Config::default());
4414 app.insert_paste_text("line 1\r\nline 2");
4415 app.insert_media_attachment("image", Path::new("/tmp/pasted.png"), Some("8x4 PNG (2KB)"));
4416
4417 let submitted = app.submit_input().expect("submitted input");
4418 assert!(submitted.contains("line 1\nline 2"));
4419 assert!(submitted.contains("[Attached image: 8x4 PNG (2KB) at /tmp/pasted.png]"));
4420
4421 app.history_up();
4422 assert_eq!(app.input, submitted);
4423 assert_eq!(app.composer_attachment_count(), 1);
4424
4425 app.clear_input();
4426 app.queue_message(QueuedMessage::new(
4427 submitted.clone(),
4428 Some("Use this skill".to_string()),
4429 ));
4430 assert!(app.pop_last_queued_into_draft());
4431 assert_eq!(app.input, submitted);
4432 assert_eq!(app.composer_attachment_count(), 1);
4433 assert_eq!(
4434 app.queued_draft
4435 .as_ref()
4436 .and_then(|draft| draft.skill_instruction.as_deref()),
4437 Some("Use this skill")
4438 );
4439
4440 app.push_pending_steer(QueuedMessage::new(submitted.clone(), None));
4441 let steers = app.drain_pending_steers();
4442 assert_eq!(steers[0].display, submitted);
4443 }
4444
4445 #[test]
4446 fn selected_attachment_row_removes_placeholder_without_manual_editing() {
4447 let mut app = App::new(test_options(false), &Config::default());
4448 app.input = "before".to_string();
4449 app.cursor_position = "before".chars().count();
4450 app.insert_media_attachment("image", Path::new("/tmp/pasted.png"), Some("8x4 PNG"));
4451 app.insert_str("after");
4452
4453 app.move_cursor_start();
4454 assert!(app.select_previous_composer_attachment());
4455 assert_eq!(app.selected_composer_attachment_index(), Some(0));
4456 assert!(app.remove_selected_composer_attachment());
4457
4458 assert!(!app.input.contains("[Attached image:"));
4459 assert!(app.input.contains("before"));
4460 assert!(app.input.contains("after"));
4461 assert_eq!(app.composer_attachment_count(), 0);
4462 assert!(app.selected_composer_attachment_index().is_none());
4463 }
4464
4465 #[test]
4466 fn kill_to_end_of_line_cuts_from_middle_of_word() {
4467 let mut app = App::new(test_options(false), &Config::default());
4468 app.input = "hello world".to_string();
4469 app.cursor_position = 6; // before 'w'
4470 assert!(app.kill_to_end_of_line());
4471 assert_eq!(app.input, "hello ");
4472 assert_eq!(app.cursor_position, 6);
4473 assert_eq!(app.kill_buffer, "world");
4474 }
4475
4476 #[test]
4477 fn kill_at_eol_consumes_following_newline() {
4478 let mut app = App::new(test_options(false), &Config::default());
4479 app.input = "line one\nline two".to_string();
4480 app.cursor_position = 8; // sitting on the '\n'
4481 assert!(app.kill_to_end_of_line());
4482 assert_eq!(app.input, "line oneline two");
4483 assert_eq!(app.cursor_position, 8);
4484 assert_eq!(app.kill_buffer, "\n");
4485
4486 // Empty input: kill is a no-op and the buffer is untouched.
4487 let mut empty = App::new(test_options(false), &Config::default());
4488 assert!(!empty.kill_to_end_of_line());
4489 assert!(empty.input.is_empty());
4490 assert!(empty.kill_buffer.is_empty());
4491 }
4492
4493 #[test]
4494 fn yank_inserts_kill_buffer_and_preserves_it() {
4495 let mut app = App::new(test_options(false), &Config::default());
4496 app.input = "abc def".to_string();
4497 app.cursor_position = 4; // before 'd'
4498 assert!(app.kill_to_end_of_line());
4499 assert_eq!(app.input, "abc ");
4500 assert_eq!(app.kill_buffer, "def");
4501
4502 // Move cursor to the start and yank twice — kill_buffer must persist.
4503 app.cursor_position = 0;
4504 assert!(app.yank());
4505 assert!(app.yank());
4506 assert_eq!(app.input, "defdefabc ");
4507 assert_eq!(app.cursor_position, 6);
4508 assert_eq!(app.kill_buffer, "def");
4509
4510 // Yank with empty buffer is a no-op.
4511 let mut empty = App::new(test_options(false), &Config::default());
4512 assert!(!empty.yank());
4513 assert!(empty.input.is_empty());
4514 }
4515
4516 // ---- Issue #90: quit confirmation timeout ----
4517
4518 #[test]
4519 fn quit_is_not_armed_by_default() {
4520 let app = App::new(test_options(false), &Config::default());
4521 assert!(!app.quit_is_armed());
4522 assert!(app.quit_armed_until.is_none());
4523 }
4524
4525 #[test]
4526 fn arm_quit_sets_two_second_window() {
4527 let mut app = App::new(test_options(false), &Config::default());
4528 app.arm_quit();
4529 assert!(app.quit_is_armed());
4530 let deadline = app.quit_armed_until.expect("deadline set");
4531 let remaining = deadline.saturating_duration_since(Instant::now());
4532 // Allow a generous margin for slow CI machines: 1.5s..=2.0s.
4533 assert!(
4534 remaining >= Duration::from_millis(1500) && remaining <= Duration::from_secs(2),
4535 "expected ~2s window, got {remaining:?}",
4536 );
4537 assert!(app.needs_redraw, "armed prompt should request a redraw");
4538 }
4539
4540 #[test]
4541 fn disarm_quit_clears_the_timer() {
4542 let mut app = App::new(test_options(false), &Config::default());
4543 app.arm_quit();
4544 app.needs_redraw = false;
4545 app.disarm_quit();
4546 assert!(!app.quit_is_armed());
4547 assert!(app.quit_armed_until.is_none());
4548 assert!(app.needs_redraw, "disarming should request a redraw");
4549 }
4550
4551 #[test]
4552 fn disarm_quit_when_not_armed_is_a_noop() {
4553 let mut app = App::new(test_options(false), &Config::default());
4554 app.needs_redraw = false;
4555 app.disarm_quit();
4556 assert!(!app.needs_redraw, "no redraw when nothing changed");
4557 }
4558
4559 #[test]
4560 fn quit_armed_expires_after_window() {
4561 let mut app = App::new(test_options(false), &Config::default());
4562 // Pin the deadline in the past to simulate a stale timer.
4563 app.quit_armed_until = Some(Instant::now() - Duration::from_millis(10));
4564 assert!(
4565 !app.quit_is_armed(),
4566 "expired timer must not count as armed"
4567 );
4568
4569 app.needs_redraw = false;
4570 app.tick_quit_armed();
4571 assert!(app.quit_armed_until.is_none(), "tick clears expired timer");
4572 assert!(
4573 app.needs_redraw,
4574 "expiry triggers a redraw to repaint footer"
4575 );
4576 }
4577
4578 #[test]
4579 fn quit_armed_tick_is_noop_within_window() {
4580 let mut app = App::new(test_options(false), &Config::default());
4581 app.arm_quit();
4582 app.needs_redraw = false;
4583 app.tick_quit_armed();
4584 assert!(
4585 app.quit_is_armed(),
4586 "tick within window keeps the timer armed"
4587 );
4588 assert!(!app.needs_redraw, "no redraw when nothing changed");
4589 }
4590
4591 #[test]
4592 fn re_arming_after_expiry_starts_a_fresh_window() {
4593 let mut app = App::new(test_options(false), &Config::default());
4594 app.quit_armed_until = Some(Instant::now() - Duration::from_secs(5));
4595 app.tick_quit_armed();
4596 assert!(app.quit_armed_until.is_none());
4597 app.arm_quit();
4598 let deadline = app.quit_armed_until.expect("re-armed");
4599 assert!(deadline > Instant::now(), "fresh deadline in the future");
4600 }
4601
4602 // ---- Issue #208: in-flight input routing ----
4603
4604 #[test]
4605 fn submit_disposition_immediate_when_idle_and_online() {
4606 let app = App::new(test_options(false), &Config::default());
4607 assert!(!app.is_loading);
4608 assert!(!app.offline_mode);
4609 assert_eq!(
4610 app.decide_submit_disposition(),
4611 SubmitDisposition::Immediate
4612 );
4613 }
4614
4615 #[test]
4616 fn submit_disposition_queue_when_busy_and_online_not_streaming() {
4617 // Bare Enter has one stable busy-state meaning even before the provider
4618 // emits its first token: queue a follow-up for the next turn.
4619 let mut app = App::new(test_options(false), &Config::default());
4620 app.is_loading = true;
4621 app.offline_mode = false;
4622 // streaming_message_index is None (default) → waiting phase
4623 assert_eq!(app.decide_submit_disposition(), SubmitDisposition::Queue);
4624 }
4625
4626 #[test]
4627 fn submit_disposition_queue_when_busy_and_streaming() {
4628 // #382: Busy + streaming → Queue (was QueueFollowUp; now unified)
4629 let mut app = App::new(test_options(false), &Config::default());
4630 app.is_loading = true;
4631 app.offline_mode = false;
4632 app.streaming_message_index = Some(0);
4633 assert_eq!(app.decide_submit_disposition(), SubmitDisposition::Queue);
4634 }
4635
4636 #[test]
4637 fn submit_disposition_queue_when_offline_and_idle() {
4638 let mut app = App::new(test_options(false), &Config::default());
4639 app.is_loading = false;
4640 app.offline_mode = true;
4641 assert_eq!(app.decide_submit_disposition(), SubmitDisposition::Queue);
4642 }
4643
4644 #[test]
4645 fn submit_disposition_offline_busy_queues() {
4646 let mut app = App::new(test_options(false), &Config::default());
4647 app.is_loading = true;
4648 app.offline_mode = true;
4649 // Offline mode always queues, even when streaming
4650 app.streaming_message_index = Some(0);
4651 assert_eq!(app.decide_submit_disposition(), SubmitDisposition::Queue);
4652 }
4653
4654 #[test]
4655 fn composer_submit_state_by_chord_matrix() {
4656 use super::{ComposerSubmitAction, ComposerSubmitChord};
4657
4658 let mut app = App::new(test_options(false), &Config::default());
4659 app.input = "hello".to_string();
4660 assert_eq!(
4661 app.decide_composer_submit(ComposerSubmitChord::Enter),
4662 ComposerSubmitAction::Submit(SubmitDisposition::Immediate)
4663 );
4664 assert_eq!(
4665 app.decide_composer_submit(ComposerSubmitChord::CtrlEnter),
4666 ComposerSubmitAction::Submit(SubmitDisposition::Immediate)
4667 );
4668
4669 app.is_loading = true;
4670 assert_eq!(
4671 app.decide_composer_submit(ComposerSubmitChord::Enter),
4672 ComposerSubmitAction::Submit(SubmitDisposition::Queue)
4673 );
4674 assert_eq!(
4675 app.decide_composer_submit(ComposerSubmitChord::CtrlEnter),
4676 ComposerSubmitAction::Submit(SubmitDisposition::Steer)
4677 );
4678
4679 app.streaming_message_index = Some(0);
4680 assert_eq!(
4681 app.decide_composer_submit(ComposerSubmitChord::Enter),
4682 ComposerSubmitAction::Submit(SubmitDisposition::Queue)
4683 );
4684 assert_eq!(
4685 app.decide_composer_submit(ComposerSubmitChord::CtrlEnter),
4686 ComposerSubmitAction::Submit(SubmitDisposition::Steer)
4687 );
4688
4689 app.queue_message(QueuedMessage::new("older queued".to_string(), None));
4690 app.input.clear();
4691 assert_eq!(
4692 app.decide_composer_submit(ComposerSubmitChord::Enter),
4693 ComposerSubmitAction::SendQueuedNow
4694 );
4695 assert_eq!(
4696 app.decide_composer_submit(ComposerSubmitChord::CtrlEnter),
4697 ComposerSubmitAction::SendQueuedNow
4698 );
4699
4700 app.input = "offline follow-up".to_string();
4701 app.offline_mode = true;
4702 assert_eq!(
4703 app.decide_composer_submit(ComposerSubmitChord::CtrlEnter),
4704 ComposerSubmitAction::Submit(SubmitDisposition::Queue)
4705 );
4706 }
4707
4708 #[test]
4709 fn bare_enter_while_streaming_stays_queue_not_steer() {
4710 let mut app = App::new(test_options(false), &Config::default());
4711 // Busy + streaming: every bare Enter queues. Steer is Ctrl+Enter only.
4712 app.is_loading = true;
4713 app.streaming_message_index = Some(0);
4714
4715 let first = app.enter_with_double_tap();
4716 assert_eq!(first, Some(SubmitDisposition::Queue));
4717 let second = app.enter_with_double_tap();
4718 assert_eq!(second, Some(SubmitDisposition::Queue));
4719 }
4720
4721 #[test]
4722 fn submit_disposition_does_not_mutate_the_queue() {
4723 let mut app = App::new(test_options(false), &Config::default());
4724 app.is_loading = true;
4725 app.streaming_message_index = Some(0);
4726 assert_eq!(app.enter_with_double_tap(), Some(SubmitDisposition::Queue));
4727 app.queue_message(QueuedMessage::new("older queued".to_string(), None));
4728 app.queue_message(QueuedMessage::new("just typed follow-up".to_string(), None));
4729 assert!(app.input.is_empty());
4730 // The event loop owns empty-Enter queue promotion. Merely asking for the
4731 // typed-submit disposition must not mutate queue state.
4732 assert_eq!(app.enter_with_double_tap(), Some(SubmitDisposition::Queue));
4733 assert_eq!(app.queued_message_count(), 2);
4734 }
4735
4736 #[test]
4737 fn sticky_error_ttl_is_capped_and_clears_on_composer_activity() {
4738 let mut app = App::new(test_options(false), &Config::default());
4739 app.set_sticky_status("workflow failed", StatusToastLevel::Error, None);
4740 let sticky = app.sticky_status.as_ref().expect("sticky error");
4741 assert_eq!(sticky.ttl_ms, Some(App::STICKY_ERROR_TTL_MS));
4742 app.insert_char('a');
4743 assert!(app.sticky_status.is_none());
4744 }
4745
4746 #[test]
4747 fn bare_enter_passes_through_when_idle() {
4748 let mut app = App::new(test_options(false), &Config::default());
4749 // Engine idle → Immediate every time.
4750 let first = app.enter_with_double_tap();
4751 assert_eq!(first, Some(SubmitDisposition::Immediate));
4752 let second = app.enter_with_double_tap();
4753 assert_eq!(second, Some(SubmitDisposition::Immediate));
4754 }
4755
4756 #[test]
4757 fn push_pending_steer_arms_resend_flag() {
4758 let mut app = App::new(test_options(false), &Config::default());
4759 assert!(!app.submit_pending_steers_after_interrupt);
4760 app.push_pending_steer(QueuedMessage::new("steer me".to_string(), None));
4761 assert_eq!(app.pending_steers.len(), 1);
4762 assert!(app.submit_pending_steers_after_interrupt);
4763 }
4764
4765 #[test]
4766 fn drain_pending_steers_clears_flag_and_returns_in_order() {
4767 let mut app = App::new(test_options(false), &Config::default());
4768 app.push_pending_steer(QueuedMessage::new("first".to_string(), None));
4769 app.push_pending_steer(QueuedMessage::new("second".to_string(), None));
4770 app.push_pending_steer(QueuedMessage::new("third".to_string(), None));
4771
4772 let drained = app.drain_pending_steers();
4773 assert_eq!(drained.len(), 3);
4774 assert_eq!(drained[0].display, "first");
4775 assert_eq!(drained[2].display, "third");
4776 assert!(app.pending_steers.is_empty());
4777 assert!(!app.submit_pending_steers_after_interrupt);
4778 }
4779
4780 #[test]
4781 fn drain_pending_steers_when_empty_is_safe() {
4782 let mut app = App::new(test_options(false), &Config::default());
4783 // Flag-only set (someone armed it manually): drain still clears it.
4784 app.submit_pending_steers_after_interrupt = true;
4785 let drained = app.drain_pending_steers();
4786 assert!(drained.is_empty());
4787 assert!(!app.submit_pending_steers_after_interrupt);
4788 }
4789
4790 #[test]
4791 fn double_push_pending_steer_is_idempotent_on_flag() {
4792 let mut app = App::new(test_options(false), &Config::default());
4793 app.push_pending_steer(QueuedMessage::new("a".to_string(), None));
4794 app.push_pending_steer(QueuedMessage::new("b".to_string(), None));
4795 assert!(app.submit_pending_steers_after_interrupt);
4796 assert_eq!(app.pending_steers.len(), 2);
4797 }
4798
4799 #[test]
4800 fn pop_last_queued_into_draft_pops_back_and_arms_draft() {
4801 let mut app = App::new(test_options(false), &Config::default());
4802 app.queue_message(QueuedMessage::new(
4803 "first".to_string(),
4804 Some("skill-A".to_string()),
4805 ));
4806 app.queue_message(QueuedMessage::new(
4807 "last".to_string(),
4808 Some("skill-B".to_string()),
4809 ));
4810
4811 assert!(app.pop_last_queued_into_draft());
4812 assert_eq!(app.input, "last");
4813 assert_eq!(app.cursor_position, "last".chars().count());
4814 assert_eq!(app.queued_messages.len(), 1);
4815 let draft = app.queued_draft.clone().expect("draft is set");
4816 assert_eq!(draft.display, "last");
4817 assert_eq!(draft.skill_instruction.as_deref(), Some("skill-B"));
4818 }
4819
4820 #[test]
4821 fn pop_last_queued_into_draft_noop_when_composer_dirty() {
4822 let mut app = App::new(test_options(false), &Config::default());
4823 app.queue_message(QueuedMessage::new("queued".to_string(), None));
4824 app.input = "typing".to_string();
4825 app.cursor_position = char_count(&app.input);
4826
4827 assert!(!app.pop_last_queued_into_draft());
4828 assert_eq!(app.input, "typing");
4829 assert_eq!(app.queued_messages.len(), 1);
4830 assert!(app.queued_draft.is_none());
4831 }
4832
4833 #[test]
4834 fn pop_last_queued_into_draft_noop_when_draft_already_armed() {
4835 let mut app = App::new(test_options(false), &Config::default());
4836 app.queue_message(QueuedMessage::new("queued".to_string(), None));
4837 app.queued_draft = Some(QueuedMessage::new("editing".to_string(), None));
4838
4839 assert!(!app.pop_last_queued_into_draft());
4840 assert_eq!(app.queued_messages.len(), 1);
4841 assert_eq!(
4842 app.queued_draft.as_ref().map(|d| d.display.as_str()),
4843 Some("editing")
4844 );
4845 }
4846
4847 #[test]
4848 fn pop_last_queued_into_draft_noop_when_queue_empty() {
4849 let mut app = App::new(test_options(false), &Config::default());
4850 assert!(!app.pop_last_queued_into_draft());
4851 assert!(app.input.is_empty());
4852 assert!(app.queued_draft.is_none());
4853 }
4854
4855 #[test]
4856 fn cancel_queued_draft_edit_restores_original_message() {
4857 let mut app = App::new(test_options(false), &Config::default());
4858 app.queue_message(QueuedMessage::new("first".to_string(), None));
4859 app.queue_message(QueuedMessage::new(
4860 "original follow-up".to_string(),
4861 Some("skill".to_string()),
4862 ));
4863 assert!(app.pop_last_queued_into_draft());
4864 app.input = "edited but not submitted".to_string();
4865 app.cursor_position = char_count(&app.input);
4866
4867 assert!(app.cancel_queued_draft_edit());
4868
4869 assert!(app.input.is_empty());
4870 assert!(app.queued_draft.is_none());
4871 assert_eq!(app.queued_messages.len(), 2);
4872 let restored = app.queued_messages.back().expect("restored message");
4873 assert_eq!(restored.display, "original follow-up");
4874 assert_eq!(restored.skill_instruction.as_deref(), Some("skill"));
4875 assert_eq!(
4876 app.clear_undo_buffer.as_deref(),
4877 Some("edited but not submitted"),
4878 "the interrupted edit remains recoverable via normal draft recovery"
4879 );
4880 }
4881
4882 #[test]
4883 fn finalize_streaming_assistant_marks_existing_cell_interrupted() {
4884 let mut app = App::new(test_options(false), &Config::default());
4885 app.add_message(HistoryCell::Assistant {
4886 content: "partial reply so far".to_string(),
4887 streaming: true,
4888 });
4889 let idx = app.history.len() - 1;
4890 app.streaming_message_index = Some(idx);
4891
4892 app.finalize_streaming_assistant_as_interrupted();
4893
4894 assert!(app.streaming_message_index.is_none());
4895 match &app.history[idx] {
4896 HistoryCell::Assistant { content, streaming } => {
4897 assert!(content.starts_with("[interrupted]"), "got: {content}");
4898 assert!(content.contains("partial reply so far"));
4899 assert!(!*streaming);
4900 }
4901 other => panic!("expected Assistant cell, got {other:?}"),
4902 }
4903 }
4904
4905 #[test]
4906 fn finalize_streaming_assistant_handles_empty_content() {
4907 let mut app = App::new(test_options(false), &Config::default());
4908 app.add_message(HistoryCell::Assistant {
4909 content: String::new(),
4910 streaming: true,
4911 });
4912 let idx = app.history.len() - 1;
4913 app.streaming_message_index = Some(idx);
4914
4915 app.finalize_streaming_assistant_as_interrupted();
4916
4917 match &app.history[idx] {
4918 HistoryCell::Assistant { content, streaming } => {
4919 assert_eq!(content, "[interrupted]");
4920 assert!(!*streaming);
4921 }
4922 other => panic!("expected Assistant cell, got {other:?}"),
4923 }
4924 }
4925
4926 #[test]
4927 fn finalize_streaming_assistant_no_op_without_index() {
4928 let mut app = App::new(test_options(false), &Config::default());
4929 // No streaming index set; should not panic and should leave history unchanged.
4930 let prev_len = app.history.len();
4931 app.finalize_streaming_assistant_as_interrupted();
4932 assert_eq!(app.history.len(), prev_len);
4933 assert!(app.streaming_message_index.is_none());
4934 }
4935
4936 #[test]
4937 fn finalize_streaming_assistant_is_idempotent_on_double_call() {
4938 let mut app = App::new(test_options(false), &Config::default());
4939 app.add_message(HistoryCell::Assistant {
4940 content: "something".to_string(),
4941 streaming: true,
4942 });
4943 let idx = app.history.len() - 1;
4944 app.streaming_message_index = Some(idx);
4945
4946 app.finalize_streaming_assistant_as_interrupted();
4947 // Second call without resetting state must be safe.
4948 app.finalize_streaming_assistant_as_interrupted();
4949
4950 match &app.history[idx] {
4951 HistoryCell::Assistant { content, .. } => {
4952 // Second call still finds index None — content unchanged from first.
4953 assert!(content.starts_with("[interrupted] "));
4954 assert_eq!(content.matches("[interrupted]").count(), 1);
4955 }
4956 other => panic!("expected Assistant cell, got {other:?}"),
4957 }
4958 }
4959
4960 #[test]
4961 fn delete_word_backward_removes_previous_word_only() {
4962 let mut app = App::new(test_options(false), &Config::default());
4963 app.input = "hello world".to_string();
4964 app.cursor_position = char_count(&app.input);
4965
4966 app.delete_word_backward();
4967
4968 assert_eq!(app.input, "hello ");
4969 assert_eq!(app.cursor_position, char_count("hello "));
4970 }
4971
4972 #[test]
4973 fn delete_word_backward_handles_trailing_space_and_utf8() {
4974 let mut app = App::new(test_options(false), &Config::default());
4975 app.input = "cafe 你好 ".to_string();
4976 app.cursor_position = char_count(&app.input);
4977
4978 app.delete_word_backward();
4979
4980 assert_eq!(app.input, "cafe ");
4981 assert_eq!(app.cursor_position, char_count("cafe "));
4982 }
4983
4984 #[test]
4985 fn delete_word_forward_handles_leading_space_and_utf8() {
4986 let mut app = App::new(test_options(false), &Config::default());
4987 app.input = "hello 你好 world".to_string();
4988 app.cursor_position = char_count("hello");
4989
4990 app.delete_word_forward();
4991
4992 assert_eq!(app.input, "hello world");
4993 assert_eq!(app.cursor_position, char_count("hello"));
4994 }
4995
4996 #[test]
4997 fn delete_to_start_of_line_respects_multiline_cursor() {
4998 let mut app = App::new(test_options(false), &Config::default());
4999 app.input = "first\nsecond line".to_string();
5000 app.cursor_position = char_count("first\nsecond");
5001
5002 app.delete_to_start_of_line();
5003
5004 assert_eq!(app.input, "first\n line");
5005 assert_eq!(app.cursor_position, char_count("first\n"));
5006 }
5007
5008 #[test]
5009 fn kill_and_yank_handle_multibyte_utf8() {
5010 let mut app = App::new(test_options(false), &Config::default());
5011 // "café 你好" — char_count = 7 (c,a,f,é, ,你,好); UTF-8 bytes differ.
5012 app.input = "café 你好".to_string();
5013 app.cursor_position = 5; // before '你'
5014 assert!(app.kill_to_end_of_line());
5015 assert_eq!(app.input, "café ");
5016 assert_eq!(app.cursor_position, 5);
5017 assert_eq!(app.kill_buffer, "你好");
5018
5019 // Yank back at the same spot — must not panic on char boundaries.
5020 assert!(app.yank());
5021 assert_eq!(app.input, "café 你好");
5022 assert_eq!(app.cursor_position, 7);
5023 }
5024
5025 #[test]
5026 fn selection_range_returns_none_when_no_anchor() {
5027 let mut app = App::new(test_options(false), &Config::default());
5028 app.input = "hello world".to_string();
5029 app.cursor_position = 5;
5030 app.selection_anchor = None;
5031 assert!(app.selection_range().is_none());
5032 }
5033
5034 #[test]
5035 fn selection_range_returns_ordered_range() {
5036 let mut app = App::new(test_options(false), &Config::default());
5037 app.input = "hello world".to_string();
5038 app.cursor_position = 5;
5039 app.selection_anchor = Some(2);
5040 assert_eq!(app.selection_range(), Some((2, 5)));
5041 }
5042
5043 #[test]
5044 fn selection_range_normalizes_order() {
5045 let mut app = App::new(test_options(false), &Config::default());
5046 app.input = "hello world".to_string();
5047 app.cursor_position = 2;
5048 app.selection_anchor = Some(5);
5049 assert_eq!(app.selection_range(), Some((2, 5)));
5050 }
5051
5052 #[test]
5053 fn selection_range_returns_none_when_anchor_equals_cursor() {
5054 let mut app = App::new(test_options(false), &Config::default());
5055 app.input = "hello".to_string();
5056 app.cursor_position = 3;
5057 app.selection_anchor = Some(3);
5058 assert!(app.selection_range().is_none());
5059 }
5060
5061 #[test]
5062 fn delete_selection_removes_selected_text() {
5063 let mut app = App::new(test_options(false), &Config::default());
5064 app.input = "hello world".to_string();
5065 app.cursor_position = 5;
5066 app.selection_anchor = Some(2);
5067 assert!(app.delete_selection());
5068 assert_eq!(app.input, "he world");
5069 assert_eq!(app.cursor_position, 2);
5070 assert!(app.selection_anchor.is_none());
5071 }
5072
5073 #[test]
5074 fn insert_char_replaces_selection() {
5075 let mut app = App::new(test_options(false), &Config::default());
5076 app.input = "hello world".to_string();
5077 app.cursor_position = 5;
5078 app.selection_anchor = Some(2);
5079 app.insert_char('X');
5080 assert_eq!(app.input, "heX world");
5081 assert_eq!(app.cursor_position, 3);
5082 assert!(app.selection_anchor.is_none());
5083 }
5084
5085 #[test]
5086 fn delete_char_removes_selection_instead_of_single_char() {
5087 let mut app = App::new(test_options(false), &Config::default());
5088 app.input = "hello world".to_string();
5089 app.cursor_position = 5;
5090 app.selection_anchor = Some(2);
5091 app.delete_char();
5092 assert_eq!(app.input, "he world");
5093 assert_eq!(app.cursor_position, 2);
5094 }
5095
5096 #[test]
5097 fn selected_text_returns_correct_substring() {
5098 let mut app = App::new(test_options(false), &Config::default());
5099 app.input = "hello world".to_string();
5100 app.cursor_position = 5;
5101 app.selection_anchor = Some(2);
5102 assert_eq!(app.selected_text(), "llo");
5103 }
5104
5105 #[test]
5106 fn insert_str_replaces_selection() {
5107 let mut app = App::new(test_options(false), &Config::default());
5108 app.input = "hello world".to_string();
5109 app.cursor_position = 5;
5110 app.selection_anchor = Some(2);
5111 app.insert_str("yo");
5112 assert_eq!(app.input, "heyo world");
5113 assert_eq!(app.cursor_position, 4);
5114 assert!(app.selection_anchor.is_none());
5115 }
5116
5117 #[test]
5118 fn delete_selection_noop_when_no_selection() {
5119 let mut app = App::new(test_options(false), &Config::default());
5120 app.input = "hello".to_string();
5121 app.cursor_position = 3;
5122 app.selection_anchor = None;
5123 assert!(!app.delete_selection());
5124 assert_eq!(app.input, "hello");
5125 assert_eq!(app.cursor_position, 3);
5126 }
5127
5128 // === Composer real-editor contract (v0.9.1) ====================================
5129
5130 #[test]
5131 fn grapheme_boundaries_snap_around_zwj_emoji_and_flags() {
5132 // "a👩‍👩‍👧‍👦b" — the family emoji is 7 chars (4 people + 3 ZWJ) but ONE grapheme.
5133 let text = "a👩‍👩‍👧‍👦b";
5134 let family_chars = "👩‍👩‍👧‍👦".chars().count();
5135 assert_eq!(family_chars, 7);
5136 // Stepping right from after 'a' jumps over the whole family.
5137 assert_eq!(next_grapheme_boundary(text, 1), 1 + family_chars);
5138 // Stepping left from before 'b' jumps back to just after 'a'.
5139 assert_eq!(prev_grapheme_boundary(text, 1 + family_chars), 1);
5140 // A cursor stranded mid-cluster snaps to the cluster edges.
5141 assert_eq!(prev_grapheme_boundary(text, 3), 1);
5142 assert_eq!(next_grapheme_boundary(text, 3), 1 + family_chars);
5143
5144 // Flag pair: two regional-indicator chars, one grapheme.
5145 let flag = "🇯🇵";
5146 assert_eq!(flag.chars().count(), 2);
5147 assert_eq!(next_grapheme_boundary(flag, 0), 2);
5148 assert_eq!(prev_grapheme_boundary(flag, 2), 0);
5149 }
5150
5151 #[test]
5152 fn cursor_moves_by_grapheme_over_emoji_and_cjk() {
5153 let mut app = App::new(test_options(false), &Config::default());
5154 app.input = "你👍🏽好".to_string(); // CJK + skin-tone emoji (2 chars) + CJK
5155 app.cursor_position = 0;
5156 app.move_cursor_right();
5157 assert_eq!(app.cursor_position, 1); // after 你
5158 app.move_cursor_right();
5159 assert_eq!(app.cursor_position, 3); // after 👍🏽 (base + modifier)
5160 app.move_cursor_right();
5161 assert_eq!(app.cursor_position, 4); // after 好
5162 app.move_cursor_right();
5163 assert_eq!(app.cursor_position, 4); // clamped at end
5164 app.move_cursor_left();
5165 assert_eq!(app.cursor_position, 3);
5166 app.move_cursor_left();
5167 assert_eq!(app.cursor_position, 1);
5168 app.move_cursor_left();
5169 assert_eq!(app.cursor_position, 0);
5170 app.move_cursor_left();
5171 assert_eq!(app.cursor_position, 0); // clamped at start
5172 }
5173
5174 #[test]
5175 fn backspace_removes_whole_emoji_cluster() {
5176 let mut app = App::new(test_options(false), &Config::default());
5177 app.input = "hi👩‍👩‍👧‍👦".to_string();
5178 app.cursor_position = char_count(&app.input);
5179 app.delete_char();
5180 assert_eq!(app.input, "hi");
5181 assert_eq!(app.cursor_position, 2);
5182 }
5183
5184 #[test]
5185 fn forward_delete_removes_whole_flag_cluster() {
5186 let mut app = App::new(test_options(false), &Config::default());
5187 app.input = "🇯🇵ok".to_string();
5188 app.cursor_position = 0;
5189 app.delete_char_forward();
5190 assert_eq!(app.input, "ok");
5191 assert_eq!(app.cursor_position, 0);
5192 }
5193
5194 #[test]
5195 fn backspace_deletes_cjk_per_character() {
5196 let mut app = App::new(test_options(false), &Config::default());
5197 app.input = "你好".to_string();
5198 app.cursor_position = 2;
5199 app.delete_char();
5200 assert_eq!(app.input, "你");
5201 app.delete_char();
5202 assert_eq!(app.input, "");
5203 }
5204
5205 #[test]
5206 fn vim_x_removes_whole_grapheme_cluster() {
5207 let mut app = App::new(test_options(false), &Config::default());
5208 app.input = "👍🏽a".to_string();
5209 app.cursor_position = 0;
5210 app.vim_delete_char_under_cursor();
5211 assert_eq!(app.input, "a");
5212 assert_eq!(app.cursor_position, 0);
5213 }
5214
5215 #[test]
5216 fn select_all_covers_whole_draft() {
5217 let mut app = App::new(test_options(false), &Config::default());
5218 app.input = "hello 你好 🇯🇵".to_string();
5219 app.cursor_position = 3;
5220 app.select_all();
5221 assert_eq!(app.selection_anchor, Some(0));
5222 assert_eq!(app.cursor_position, char_count(&app.input));
5223 assert_eq!(app.selected_text(), "hello 你好 🇯🇵");
5224 }
5225
5226 #[test]
5227 fn select_all_on_empty_composer_sets_no_anchor() {
5228 let mut app = App::new(test_options(false), &Config::default());
5229 app.select_all();
5230 assert!(app.selection_anchor.is_none());
5231 assert!(app.selection_range().is_none());
5232 }
5233
5234 #[test]
5235 fn select_all_then_typing_replaces_everything_recoverably() {
5236 let mut app = App::new(test_options(false), &Config::default());
5237 app.input = "precious draft".to_string();
5238 app.select_all();
5239 app.insert_char('x');
5240 assert_eq!(app.input, "x");
5241 assert_eq!(app.cursor_position, 1);
5242 // The overwritten draft is stashed like Ctrl+U would.
5243 assert_eq!(app.clear_undo_buffer.as_deref(), Some("precious draft"));
5244 assert!(app.draft_history.iter().any(|d| d == "precious draft"));
5245 }
5246
5247 #[test]
5248 fn select_all_then_backspace_is_recoverable_with_ctrl_z() {
5249 let mut app = App::new(test_options(false), &Config::default());
5250 app.input = "do not lose me".to_string();
5251 app.select_all();
5252 app.delete_char();
5253 assert_eq!(app.input, "");
5254 assert!(app.restore_last_cleared_input_if_empty());
5255 assert_eq!(app.input, "do not lose me");
5256 assert_eq!(app.cursor_position, char_count(&app.input));
5257 }
5258
5259 #[test]
5260 fn partial_selection_delete_does_not_stash_undo_buffer() {
5261 let mut app = App::new(test_options(false), &Config::default());
5262 app.input = "hello world".to_string();
5263 app.selection_anchor = Some(0);
5264 app.cursor_position = 5;
5265 assert!(app.delete_selection());
5266 assert_eq!(app.input, " world");
5267 assert!(app.clear_undo_buffer.is_none());
5268 }
5269
5270 #[test]
5271 fn delete_selection_handles_cjk_and_emoji_ranges() {
5272 let mut app = App::new(test_options(false), &Config::default());
5273 app.input = "a你👩‍👩‍👧‍👦好b".to_string();
5274 // Select 你 + family emoji (7 chars) + 好: chars 1..10.
5275 app.selection_anchor = Some(1);
5276 app.cursor_position = 10;
5277 assert_eq!(app.selected_text(), "你👩‍👩‍👧‍👦好");
5278 assert!(app.delete_selection());
5279 assert_eq!(app.input, "ab");
5280 assert_eq!(app.cursor_position, 1);
5281 }
5282
5283 #[test]
5284 fn shift_home_end_style_selection_uses_line_bounds() {
5285 let mut app = App::new(test_options(false), &Config::default());
5286 app.input = "first line\nsecond line".to_string();
5287 // Cursor in the middle of the second line ("second ".len() == 7).
5288 app.cursor_position = 11 + 7;
5289 // Shift+Home: anchor at cursor, move to line start.
5290 app.selection_anchor = Some(app.cursor_position);
5291 app.move_cursor_line_start();
5292 assert_eq!(app.cursor_position, 11);
5293 assert_eq!(app.selected_text(), "second ");
5294 // Shift+End from the same anchor: move to line end.
5295 app.move_cursor_line_end();
5296 assert_eq!(app.cursor_position, char_count(&app.input));
5297 assert_eq!(app.selected_text(), "line");
5298 }
5299
5300 #[test]
5301 fn word_selection_extends_by_word_and_replaces_on_type() {
5302 let mut app = App::new(test_options(false), &Config::default());
5303 app.input = "alpha beta gamma".to_string();
5304 app.cursor_position = 0;
5305 // Ctrl/Alt+Shift+Right twice: anchor once, extend word-wise.
5306 app.selection_anchor = Some(app.cursor_position);
5307 app.move_cursor_word_forward();
5308 app.move_cursor_word_forward();
5309 assert_eq!(app.selected_text(), "alpha beta ");
5310 app.insert_char('X');
5311 assert_eq!(app.input, "Xgamma");
5312 assert_eq!(app.cursor_position, 1);
5313 }
5314
5315 // === #2574: capability-aware fallback eligibility ===============================
5316
5317 /// Build an `App` whose fallback chain is `[active, fallbacks...]` with each
5318 /// provider's auth controlled via `config.providers` keys. The startup-default
5319 /// settings home is isolated too: an intentional saved default from a previous
5320 /// test or a developer's real profile must not replace the chain primary.
5321 fn app_with_fallback_chain(
5322 active: ApiProvider,
5323 fallbacks: &[codewhale_config::ProviderKind],
5324 keyed: &[ApiProvider],
5325 ) -> App {
5326 let settings_home = tempfile::tempdir().expect("isolated fallback settings home");
5327 let _home = EnvVarGuard::set("HOME", settings_home.path());
5328 let _user_profile = EnvVarGuard::set("USERPROFILE", settings_home.path());
5329 let _codewhale_home =
5330 EnvVarGuard::set("CODEWHALE_HOME", settings_home.path().join(".codewhale"));
5331 let _deepseek_config = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
5332 let _codewhale_config = EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
5333 let mut providers = ProvidersConfig::default();
5334 for provider in keyed {
5335 let entry = ProviderConfig {
5336 api_key: Some(format!("test-key-{}", provider.as_str())),
5337 ..Default::default()
5338 };
5339 match provider {
5340 ApiProvider::Deepseek => providers.deepseek = entry,
5341 ApiProvider::Openai => providers.openai = entry,
5342 ApiProvider::Openrouter => providers.openrouter = entry,
5343 ApiProvider::Together => providers.together = entry,
5344 ApiProvider::Fireworks => providers.fireworks = entry,
5345 other => panic!("unhandled keyed provider in test helper: {other:?}"),
5346 }
5347 }
5348
5349 let config = Config {
5350 provider: Some(active.as_str().to_string()),
5351 fallback_providers: fallbacks.to_vec(),
5352 providers: Some(providers),
5353 ..Default::default()
5354 };
5355
5356 let mut options = test_options(false);
5357 options.start_in_agent_mode = true;
5358 options.skip_onboarding = true;
5359 App::new(options, &config)
5360 }
5361
5362 #[test]
5363 fn advance_fallback_skips_unauthed_middle_provider_and_lands_on_next_ready() {
5364 let _lock = lock_test_env();
5365 let _openai = EnvVarGuard::remove("OPENAI_API_KEY");
5366 let _openrouter = EnvVarGuard::remove("OPENROUTER_API_KEY");
5367 let _together = EnvVarGuard::remove("TOGETHER_API_KEY");
5368
5369 // Chain: Openai (active, keyed) -> Openrouter (no key) -> Together (keyed).
5370 let mut app = app_with_fallback_chain(
5371 ApiProvider::Openai,
5372 &[
5373 codewhale_config::ProviderKind::Openrouter,
5374 codewhale_config::ProviderKind::Together,
5375 ],
5376 &[ApiProvider::Openai, ApiProvider::Together],
5377 );
5378 assert_eq!(app.fallback_chain_position(), Some(0));
5379
5380 // Openrouter is skipped (needs auth); we land on Together.
5381 let next = app.advance_fallback("network error");
5382 assert_eq!(next, Some(ApiProvider::Together));
5383 assert_eq!(app.api_provider, ApiProvider::Together);
5384 assert_eq!(app.fallback_chain_position(), Some(2));
5385
5386 let reason = app.last_fallback_reason.as_deref().unwrap_or_default();
5387 assert!(
5388 reason.contains("Fell back to together"),
5389 "reason should name the landed provider: {reason}"
5390 );
5391 assert!(
5392 reason.contains("skipped openrouter: needs auth"),
5393 "reason should note the skipped provider: {reason}"
5394 );
5395 }
5396
5397 #[test]
5398 fn advance_fallback_local_provider_is_eligible_without_a_key() {
5399 let _lock = lock_test_env();
5400 let _openai = EnvVarGuard::remove("OPENAI_API_KEY");
5401
5402 // Chain: Openai (active, keyed) -> Ollama (local, no key needed).
5403 let mut app = app_with_fallback_chain(
5404 ApiProvider::Openai,
5405 &[codewhale_config::ProviderKind::Ollama],
5406 &[ApiProvider::Openai],
5407 );
5408
5409 let next = app.advance_fallback("timeout");
5410 assert_eq!(
5411 next,
5412 Some(ApiProvider::Ollama),
5413 "self-hosted providers are ready without a key"
5414 );
5415 assert_eq!(app.api_provider, ApiProvider::Ollama);
5416 let reason = app.last_fallback_reason.as_deref().unwrap_or_default();
5417 assert!(reason.contains("Fell back to ollama"), "{reason}");
5418 assert!(
5419 !reason.contains("skipped"),
5420 "no providers should be skipped: {reason}"
5421 );
5422 }
5423
5424 #[test]
5425 fn advance_fallback_all_unready_exhausts_with_clear_reason() {
5426 let _lock = lock_test_env();
5427 let _openai = EnvVarGuard::remove("OPENAI_API_KEY");
5428 let _openrouter = EnvVarGuard::remove("OPENROUTER_API_KEY");
5429 let _together = EnvVarGuard::remove("TOGETHER_API_KEY");
5430
5431 // Chain: Openai (active, keyed) -> Openrouter (no key) -> Together (no key).
5432 // Every fallback entry is unready, so the chain exhausts.
5433 let mut app = app_with_fallback_chain(
5434 ApiProvider::Openai,
5435 &[
5436 codewhale_config::ProviderKind::Openrouter,
5437 codewhale_config::ProviderKind::Together,
5438 ],
5439 &[ApiProvider::Openai],
5440 );
5441
5442 let next = app.advance_fallback("rate limited");
5443 assert_eq!(next, None, "no ready fallback remains");
5444 // Active provider is unchanged on exhaustion.
5445 assert_eq!(app.api_provider, ApiProvider::Openai);
5446
5447 let reason = app.last_fallback_reason.as_deref().unwrap_or_default();
5448 assert!(
5449 reason.contains("Fallback chain exhausted"),
5450 "reason should state exhaustion: {reason}"
5451 );
5452 assert!(
5453 reason.contains("skipped openrouter: needs auth")
5454 && reason.contains("skipped together: needs auth"),
5455 "reason should note every skipped provider: {reason}"
5456 );
5457 }
5458
5459 #[test]
5460 fn startup_and_fallback_skip_inactive_external_only_routes_without_io() {
5461 let _lock = lock_test_env();
5462 let temp = tempfile::tempdir().expect("external fallback fixtures");
5463 let codex_path = temp.path().join("codex-auth.json");
5464 let grok_path = temp.path().join("grok-auth.json");
5465 let codex_raw = "inactive Codex bytes must not be read";
5466 let grok_raw = "inactive Grok bytes must not be read";
5467 std::fs::write(&codex_path, codex_raw).expect("write Codex trap");
5468 std::fs::write(&grok_path, grok_raw).expect("write Grok trap");
5469 let _home = EnvVarGuard::set("CODEWHALE_HOME", temp.path().join("owned-home"));
5470 let _codex_path = EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &codex_path);
5471 let _grok_path = EnvVarGuard::set("GROK_AUTH_PATH", &grok_path);
5472 let _codex_access = EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
5473 let _legacy_codex_access = EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
5474 let _xai_key = EnvVarGuard::remove("XAI_API_KEY");
5475 let _cli_key = EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
5476 let _cli_source = EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
5477
5478 let config = Config {
5479 provider: Some(ApiProvider::Deepseek.as_str().to_string()),
5480 api_key: Some("active-deepseek-key".to_string()),
5481 fallback_providers: vec![
5482 codewhale_config::ProviderKind::OpenaiCodex,
5483 codewhale_config::ProviderKind::Xai,
5484 ],
5485 providers: Some(ProvidersConfig {
5486 openai_codex: ProviderConfig {
5487 auth_mode: Some("oauth".to_string()),
5488 external_credentials: Some(
5489 codewhale_config::ExternalCredentialConsentToml::read_only(
5490 codewhale_config::ProviderKind::OpenaiCodex,
5491 codewhale_config::ExternalCredentialSource::CodexCli,
5492 codex_path.clone(),
5493 ),
5494 ),
5495 ..Default::default()
5496 },
5497 xai: ProviderConfig {
5498 auth_mode: Some("oauth".to_string()),
5499 external_credentials: Some(
5500 codewhale_config::ExternalCredentialConsentToml::read_only(
5501 codewhale_config::ProviderKind::Xai,
5502 codewhale_config::ExternalCredentialSource::GrokCli,
5503 grok_path.clone(),
5504 ),
5505 ),
5506 ..Default::default()
5507 },
5508 ..Default::default()
5509 }),
5510 ..Default::default()
5511 };
5512 let mut options = test_options(false);
5513 options.skip_onboarding = true;
5514
5515 crate::external_credentials::reset_side_effect_trap();
5516 let mut app = App::new(options, &config);
5517 assert_eq!(
5518 crate::external_credentials::side_effect_trap_counts(),
5519 (0, 0),
5520 "startup readiness must not inspect inactive external credentials"
5521 );
5522 assert_eq!(app.advance_fallback("active route unavailable"), None);
5523 assert_eq!(
5524 crate::external_credentials::side_effect_trap_counts(),
5525 (0, 0),
5526 "fallback selection must skip external-only inactive routes without inspection"
5527 );
5528 let reason = app.last_fallback_reason.as_deref().unwrap_or_default();
5529 assert!(
5530 reason.contains("skipped openai-codex: needs auth"),
5531 "{reason}"
5532 );
5533 assert!(reason.contains("skipped xai: needs auth"), "{reason}");
5534 assert_eq!(
5535 std::fs::read_to_string(&codex_path).expect("Codex trap unchanged"),
5536 codex_raw
5537 );
5538 assert_eq!(
5539 std::fs::read_to_string(&grok_path).expect("Grok trap unchanged"),
5540 grok_raw
5541 );
5542 }
5543
5544 #[test]
5545 fn advance_fallback_local_primary_does_not_fall_back_to_cloud() {
5546 let _lock = lock_test_env();
5547 let _openai = EnvVarGuard::remove("OPENAI_API_KEY");
5548 let _deepseek = EnvVarGuard::remove("DEEPSEEK_API_KEY");
5549
5550 // Local primary (Ollama) -> cloud fallback (DeepSeek, fully keyed). The
5551 // cloud entry is policy-blocked even though it is otherwise ready, so the
5552 // chain exhausts rather than leaking a local/private route out to cloud.
5553 let mut app = app_with_fallback_chain(
5554 ApiProvider::Ollama,
5555 &[codewhale_config::ProviderKind::Deepseek],
5556 &[ApiProvider::Deepseek],
5557 );
5558
5559 let next = app.advance_fallback("local runtime unavailable");
5560 assert_eq!(next, None, "local->cloud fallback must be blocked");
5561 assert_eq!(app.api_provider, ApiProvider::Ollama);
5562
5563 let reason = app.last_fallback_reason.as_deref().unwrap_or_default();
5564 assert!(
5565 reason.contains("local/private policy"),
5566 "block reason must be visible and specific: {reason}"
5567 );
5568 assert!(
5569 !reason.contains("needs auth"),
5570 "the block is policy, not missing auth: {reason}"
5571 );
5572 }
5573
5574 #[test]
5575 fn advance_fallback_local_primary_may_fall_back_to_local_sibling() {
5576 let _lock = lock_test_env();
5577
5578 // Local primary (Ollama) -> local sibling (vLLM). Both are self-hosted, so
5579 // the local/private posture is preserved and the fallback is allowed.
5580 let mut app = app_with_fallback_chain(
5581 ApiProvider::Ollama,
5582 &[codewhale_config::ProviderKind::Vllm],
5583 &[],
5584 );
5585
5586 let next = app.advance_fallback("local runtime unavailable");
5587 assert_eq!(
5588 next,
5589 Some(ApiProvider::Vllm),
5590 "local->local fallback stays within the private posture"
5591 );
5592 assert_eq!(app.api_provider, ApiProvider::Vllm);
5593 let reason = app.last_fallback_reason.as_deref().unwrap_or_default();
5594 assert!(reason.contains("Fell back to vllm"), "{reason}");
5595 }
5596
5597 #[test]
5598 fn advance_fallback_cloud_primary_can_hop_cloud_to_local_to_cloud() {
5599 let _lock = lock_test_env();
5600 let _openai = EnvVarGuard::remove("OPENAI_API_KEY");
5601 let _deepseek = EnvVarGuard::remove("DEEPSEEK_API_KEY");
5602
5603 // The local/private guard is origin-based. A cloud primary may route to a
5604 // local fallback and then to another cloud fallback if the cloud candidate
5605 // is otherwise ready; only local/private primaries are blocked from leaking
5606 // out to cloud.
5607 let mut app = app_with_fallback_chain(
5608 ApiProvider::Openai,
5609 &[
5610 codewhale_config::ProviderKind::Ollama,
5611 codewhale_config::ProviderKind::Deepseek,
5612 ],
5613 &[ApiProvider::Openai, ApiProvider::Deepseek],
5614 );
5615
5616 let local = app.advance_fallback("cloud provider timed out");
5617 assert_eq!(local, Some(ApiProvider::Ollama));
5618 assert_eq!(app.api_provider, ApiProvider::Ollama);
5619
5620 let cloud = app.advance_fallback("local runtime unavailable");
5621 assert_eq!(cloud, Some(ApiProvider::Deepseek));
5622 assert_eq!(app.api_provider, ApiProvider::Deepseek);
5623
5624 let reason = app.last_fallback_reason.as_deref().unwrap_or_default();
5625 assert!(reason.contains("Fell back to deepseek"), "{reason}");
5626 assert!(
5627 !reason.contains("local/private policy"),
5628 "cloud-primary chains should not trigger local/private blocking: {reason}"
5629 );
5630 }
5631
5632 #[test]
5633 fn status_classifier_does_not_paint_negated_success_green() {
5634 use super::StatusToastLevel;
5635 // Failures that happen to contain a success keyword ("saved", "found")
5636 // must not toast green (#3757 UX review).
5637 let (level, _, _) = App::classify_status_text("Custom provider was not saved.");
5638 assert_ne!(level, StatusToastLevel::Success);
5639 let (level, _, _) = App::classify_status_text("Queued message not found");
5640 assert_ne!(level, StatusToastLevel::Success);
5641 let (level, _, _) = App::classify_status_text("Could not enable subagents");
5642 assert_ne!(level, StatusToastLevel::Success);
5643 let (level, _, _) = App::classify_status_text("No sessions found");
5644 assert_ne!(level, StatusToastLevel::Success);
5645
5646 // Genuine successes still classify green.
5647 let (level, _, _) = App::classify_status_text("Fleet profile saved: reviewer.toml");
5648 assert_eq!(level, StatusToastLevel::Success);
5649
5650 // Both cancel spellings classify as Warning.
5651 let (level, _, _) = App::classify_status_text("Turn canceled");
5652 assert_eq!(level, StatusToastLevel::Warning);
5653 let (level, _, _) = App::classify_status_text("Turn cancelled");
5654 assert_eq!(level, StatusToastLevel::Warning);
5655 }
5656
5657 #[test]
5658 fn onboarding_provider_copy_is_provider_neutral_in_en() {
5659 use crate::localization::{Locale, MessageId, tr};
5660
5661 let title = tr(Locale::En, MessageId::OnboardProviderTitle);
5662 let blurb = tr(Locale::En, MessageId::OnboardProviderBlurb);
5663 let api_title = tr(Locale::En, MessageId::OnboardApiKeyTitle);
5664 assert!(!title.to_ascii_lowercase().contains("deepseek"), "{title}");
5665 assert!(!blurb.to_ascii_lowercase().contains("deepseek"), "{blurb}");
5666 assert!(
5667 !api_title.to_ascii_lowercase().contains("deepseek"),
5668 "{api_title}"
5669 );
5670 }
5671
5672 #[test]
5673 fn agent_current_activity_bounds_redacts_and_strips_control_sequences() {
5674 let secret = "sk-activity-secret-1234567890";
5675 let raw = format!(
5676 "\u{1b}[31mrunning\u{1b}[0m\napi_key={secret}\n\u{1b}]8;;https://example.invalid\u{7}details\u{1b}]8;;\u{7}\u{1}"
5677 );
5678 let activity = AgentCurrentActivity::bounded(
5679 AgentCurrentActivityStatus::Running,
5680 Some(raw.clone()),
5681 Some(format!("\u{1b}[33mFile.read\u{1b}[0m {secret}")),
5682 Some(4),
5683 );
5684
5685 let detail = activity.detail.expect("bounded detail");
5686 let tool = activity.current_tool.expect("bounded tool");
5687 assert!(detail.contains("running"), "{detail:?}");
5688 assert!(detail.contains("api_key=[redacted]"), "{detail:?}");
5689 assert!(detail.contains("details"), "{detail:?}");
5690 assert!(tool.contains("File.read"), "{tool:?}");
5691 assert!(tool.contains("[redacted]"), "{tool:?}");
5692 for safe in [&detail, &tool] {
5693 assert!(!safe.contains(secret), "{safe:?}");
5694 assert!(!safe.contains('\u{1b}'), "{safe:?}");
5695 assert!(!safe.contains('\u{1}'), "{safe:?}");
5696 assert!(!safe.contains("example.invalid"), "{safe:?}");
5697 }
5698 assert_eq!(activity.step, Some(4));
5699 assert_eq!(
5700 raw.matches(secret).count(),
5701 1,
5702 "source text stays untouched"
5703 );
5704 }
5705
5706 // ---------------------------------------------------------------------------
5707 // Startup-default persistence (mode + thinking)
5708 // ---------------------------------------------------------------------------
5709 //
5710 // Before this lane, `settings.default_mode` was written in exactly two places
5711 // — a setup-preset apply and `/config` — so interactive mode cycling never
5712 // persisted and Operate silently reverted to Act on restart. Reasoning effort
5713 // persisted, but only through the model/effort picker, so Ctrl+T and the
5714 // hotbar `reasoning.cycle` action were equally lossy.
5715
5716 /// Seal `HOME`/`CODEWHALE_HOME` onto a temp dir so these tests can assert the
5717 /// real write/reload round trip without touching the developer's settings.
5718 fn sealed_settings_home(tmp: &std::path::Path) -> Vec<EnvVarGuard> {
5719 vec![
5720 EnvVarGuard::set("HOME", tmp),
5721 EnvVarGuard::set("USERPROFILE", tmp),
5722 EnvVarGuard::set("CODEWHALE_HOME", tmp.join(".codewhale")),
5723 EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH"),
5724 EnvVarGuard::remove("CODEWHALE_CONFIG_PATH"),
5725 ]
5726 }
5727
5728 #[test]
5729 fn interactive_mode_cycle_persists_the_startup_default() {
5730 let _lock = lock_test_env();
5731 let tmp = tempfile::TempDir::new().expect("tempdir");
5732 let _env = sealed_settings_home(tmp.path());
5733 let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
5734
5735 let mut app = App::new(test_options(false), &Config::default());
5736 app.mode = AppMode::Agent;
5737 app.cycle_mode();
5738
5739 assert_eq!(
5740 app.mode,
5741 AppMode::Operate,
5742 "Act -> Operate is the Tab cycle"
5743 );
5744 let reloaded = Settings::load().expect("reload settings");
5745 assert_eq!(
5746 reloaded.default_mode, "operate",
5747 "the mode the user cycled into must be the startup default"
5748 );
5749 assert_eq!(
5750 AppMode::from_setting(&reloaded.default_mode),
5751 AppMode::Operate,
5752 "a restart must restore the last user choice"
5753 );
5754 assert!(
5755 app.startup_defaults.drain_failures().is_empty(),
5756 "a successful write must not report a failure"
5757 );
5758 }
5759
5760 #[test]
5761 fn explicit_mode_selection_and_hotbar_share_the_persistence_owner() {
5762 let _lock = lock_test_env();
5763 let tmp = tempfile::TempDir::new().expect("tempdir");
5764 let _env = sealed_settings_home(tmp.path());
5765 let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
5766
5767 let mut app = App::new(test_options(false), &Config::default());
5768 assert_eq!(app.select_mode(AppMode::Plan), SettingSelection::Changed);
5769 assert_eq!(Settings::load().expect("reload").default_mode, "plan");
5770
5771 // The legacy YOLO entry point installs Act, so that is what must persist —
5772 // "yolo" is a permission alias, never a startup mode.
5773 assert_eq!(app.select_mode(AppMode::Yolo), SettingSelection::Changed);
5774 assert_eq!(Settings::load().expect("reload").default_mode, "agent");
5775 }
5776
5777 #[test]
5778 fn session_restore_and_effective_turn_paths_do_not_rewrite_the_startup_default() {
5779 let _lock = lock_test_env();
5780 let tmp = tempfile::TempDir::new().expect("tempdir");
5781 let _env = sealed_settings_home(tmp.path());
5782 let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
5783
5784 let mut app = App::new(test_options(false), &Config::default());
5785 app.select_mode(AppMode::Plan);
5786 assert_eq!(Settings::load().expect("reload").default_mode, "plan");
5787
5788 // `set_mode` is the session-only primitive used by session restore and
5789 // preset application. It must move the live session without claiming the
5790 // user picked a new startup default.
5791 assert!(app.set_mode(AppMode::Operate));
5792 assert_eq!(app.mode, AppMode::Operate);
5793 assert_eq!(
5794 Settings::load().expect("reload").default_mode,
5795 "plan",
5796 "restoring a session must not rewrite the startup default"
5797 );
5798 }
5799
5800 #[test]
5801 fn reselecting_restored_live_mode_updates_the_startup_default() {
5802 let _lock = lock_test_env();
5803 let tmp = tempfile::TempDir::new().expect("tempdir");
5804 let _env = sealed_settings_home(tmp.path());
5805 let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
5806
5807 Settings::transact(|settings| {
5808 settings.default_mode = "agent".to_string();
5809 Ok(())
5810 })
5811 .expect("seed startup default");
5812 let mut app = App::new(test_options(false), &Config::default());
5813 assert!(app.set_mode(AppMode::Operate), "simulate session restore");
5814 assert_eq!(Settings::load().expect("reload").default_mode, "agent");
5815
5816 assert_eq!(
5817 app.select_mode(AppMode::Operate),
5818 SettingSelection::PersistedSame,
5819 "an accepted selection that did not move live mode is not a refusal"
5820 );
5821 assert_eq!(
5822 Settings::load().expect("reload").default_mode,
5823 "operate",
5824 "the explicit same-live selection must still become the startup default"
5825 );
5826 }
5827
5828 #[test]
5829 fn mode_change_refused_while_a_turn_runs_persists_nothing() {
5830 let _lock = lock_test_env();
5831 let tmp = tempfile::TempDir::new().expect("tempdir");
5832 let _env = sealed_settings_home(tmp.path());
5833 let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
5834
5835 let mut app = App::new(test_options(false), &Config::default());
5836 app.select_mode(AppMode::Plan);
5837 app.is_loading = true;
5838 app.cycle_mode();
5839
5840 assert_eq!(app.mode, AppMode::Plan, "#2982 lock still holds");
5841 assert_eq!(
5842 Settings::load().expect("reload").default_mode,
5843 "plan",
5844 "a refused change must not be persisted"
5845 );
5846 }
5847
5848 #[test]
5849 fn reasoning_cycle_persists_through_the_same_owner_as_the_picker() {
5850 let _lock = lock_test_env();
5851 let tmp = tempfile::TempDir::new().expect("tempdir");
5852 let _env = sealed_settings_home(tmp.path());
5853 let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
5854
5855 let mut app = App::new(test_options(false), &Config::default());
5856 app.api_provider = ApiProvider::Deepseek;
5857 app.auto_model = false;
5858 app.reasoning_effort = ReasoningEffort::Off;
5859
5860 // Ctrl+T and the hotbar `reasoning.cycle` action both land in
5861 // `apply_reasoning_effort_cycle`.
5862 app.apply_reasoning_effort_cycle();
5863
5864 assert_eq!(app.reasoning_effort, ReasoningEffort::High);
5865 assert_eq!(
5866 Settings::load()
5867 .expect("reload settings")
5868 .reasoning_effort
5869 .as_deref(),
5870 Some("high"),
5871 "a restart must restore the last thinking choice"
5872 );
5873 }
5874
5875 #[test]
5876 fn failed_startup_default_write_is_reported_not_swallowed() {
5877 let _lock = lock_test_env();
5878 let tmp = tempfile::TempDir::new().expect("tempdir");
5879 // A regular file where the home directory must be: every settings write
5880 // below it fails.
5881 let blocked_home = tmp.path().join("codewhale-home-file");
5882 std::fs::write(&blocked_home, "not a directory").expect("blocking file");
5883 let _home = EnvVarGuard::set("HOME", tmp.path());
5884 let _user_profile = EnvVarGuard::set("USERPROFILE", tmp.path());
5885 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &blocked_home);
5886 let _deepseek_config = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
5887 let _codewhale_config = EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
5888 let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
5889
5890 let mut app = App::new(test_options(false), &Config::default());
5891 assert_eq!(
5892 app.select_mode(AppMode::Plan),
5893 SettingSelection::Changed,
5894 "the live session still changes; only the durable write fails"
5895 );
5896 assert_eq!(app.mode, AppMode::Plan);
5897
5898 app.drain_startup_default_failures();
5899 let toast = app
5900 .status_toasts
5901 .iter()
5902 .find(|toast| toast.text.contains("startup mode"))
5903 .expect("a failed startup-default write must surface a toast");
5904 assert!(
5905 toast.text.contains("was not saved"),
5906 "toast must say the write did not land, got {:?}",
5907 toast.text
5908 );
5909 assert!(
5910 !toast.text.contains(".codewhale"),
5911 "a failure toast must not carry the settings path, got {:?}",
5912 toast.text
5913 );
5914 }
5915
5916 // ---------------------------------------------------------------------------
5917 // Startup-default write ordering
5918 // ---------------------------------------------------------------------------
5919 //
5920 // Each write is a load / modify / save transaction over one `settings.toml`.
5921 // These tests run on a real multi-threaded runtime so the writes actually go
5922 // through `spawn_blocking`, and assert the outcome is decided by the order the
5923 // user acted in — not by which blocking task the scheduler happened to pick.
5924 // `StartupDefaultsWriter::flush` is the determinism hook: it blocks until the
5925 // queue is empty and no transaction is in flight.
5926
5927 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
5928 async fn rapid_mode_selections_persist_the_last_one_not_the_last_to_finish() {
5929 let _lock = lock_test_env();
5930 let tmp = tempfile::TempDir::new().expect("tempdir");
5931 let _env = sealed_settings_home(tmp.path());
5932 let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
5933
5934 let mut app = App::new(test_options(false), &Config::default());
5935 // Faster than a human can Tab, and deliberately revisiting modes so a
5936 // reordered transaction would land on a value that is also "plausible".
5937 for mode in [
5938 AppMode::Plan,
5939 AppMode::Operate,
5940 AppMode::Agent,
5941 AppMode::Plan,
5942 AppMode::Operate,
5943 AppMode::Agent,
5944 AppMode::Plan,
5945 ] {
5946 app.select_mode(mode);
5947 }
5948 app.startup_defaults.flush();
5949
5950 assert_eq!(app.mode, AppMode::Plan);
5951 assert_eq!(
5952 Settings::load().expect("reload").default_mode,
5953 "plan",
5954 "the last selection must win, whatever order the writers ran in"
5955 );
5956 assert!(
5957 app.startup_defaults.drain_failures().is_empty(),
5958 "no write in the burst may fail"
5959 );
5960 }
5961
5962 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
5963 async fn rapid_thinking_selections_persist_the_last_one() {
5964 let _lock = lock_test_env();
5965 let tmp = tempfile::TempDir::new().expect("tempdir");
5966 let _env = sealed_settings_home(tmp.path());
5967 let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
5968
5969 let mut app = App::new(test_options(false), &Config::default());
5970 app.api_provider = ApiProvider::Deepseek;
5971 app.auto_model = false;
5972 app.reasoning_effort = ReasoningEffort::Off;
5973
5974 for _ in 0..6 {
5975 app.apply_reasoning_effort_cycle();
5976 }
5977 app.startup_defaults.flush();
5978
5979 let expected = app.reasoning_effort.as_setting_for_route(
5980 app.api_provider,
5981 &app.active_route_base_url,
5982 &app.model,
5983 );
5984 assert_eq!(
5985 Settings::load()
5986 .expect("reload")
5987 .reasoning_effort
5988 .as_deref(),
5989 Some(expected),
5990 "the tier the session ended on must be the tier on disk"
5991 );
5992 }
5993
5994 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
5995 async fn fixed_route_thinking_cycle_persists_raw_preference() {
5996 let _lock = lock_test_env();
5997 let tmp = tempfile::TempDir::new().expect("tempdir");
5998 let _env = sealed_settings_home(tmp.path());
5999 let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
6000
6001 let mut app = App::new(test_options(false), &Config::default());
6002 app.api_provider = ApiProvider::Moonshot;
6003 app.auto_model = false;
6004 app.active_route_base_url = crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string();
6005 app.model = crate::config::MOONSHOT_KIMI_K3_MODEL.to_string();
6006 app.reasoning_effort = ReasoningEffort::Max;
6007
6008 app.apply_reasoning_effort_cycle();
6009 app.startup_defaults.flush();
6010
6011 assert_eq!(app.reasoning_effort, ReasoningEffort::Off);
6012 assert_eq!(app.reasoning_effort_preference, Some(ReasoningEffort::Off));
6013 assert_eq!(
6014 Settings::load()
6015 .expect("reload")
6016 .reasoning_effort
6017 .as_deref(),
6018 Some("off"),
6019 "the always-thinking route may execute Low, but the raw Off preference must survive restart"
6020 );
6021 }
6022
6023 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
6024 async fn interleaved_mode_thinking_and_model_writes_do_not_clobber_each_other() {
6025 let _lock = lock_test_env();
6026 let tmp = tempfile::TempDir::new().expect("tempdir");
6027 let _env = sealed_settings_home(tmp.path());
6028 let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
6029
6030 let mut app = App::new(test_options(false), &Config::default());
6031 app.api_provider = ApiProvider::Deepseek;
6032 app.auto_model = false;
6033 app.reasoning_effort = ReasoningEffort::Off;
6034
6035 // Queued, non-blocking: mode then thinking.
6036 assert_eq!(app.select_mode(AppMode::Plan), SettingSelection::Changed);
6037 app.apply_reasoning_effort_cycle();
6038 let cycled_effort = app.reasoning_effort.as_setting_for_route(
6039 app.api_provider,
6040 &app.active_route_base_url,
6041 &app.model,
6042 );
6043
6044 // The model picker's synchronous write. It must apply *behind* the two
6045 // queued selections above, so neither is lost and neither is re-applied
6046 // over a newer value.
6047 app.startup_defaults
6048 .apply_blocking(
6049 crate::tui::startup_defaults::StartupDefaults::default()
6050 .with_default_model("deepseek-chat"),
6051 )
6052 .expect("model write must land");
6053
6054 let after_model = Settings::load().expect("reload");
6055 let persisted_model = after_model
6056 .default_model
6057 .clone()
6058 .expect("model picker write must be on disk");
6059 assert_eq!(
6060 after_model.default_mode, "plan",
6061 "the queued mode selection must have been applied before the model write"
6062 );
6063 assert_eq!(
6064 after_model.reasoning_effort.as_deref(),
6065 Some(cycled_effort),
6066 "the queued thinking selection must not be lost by the model write"
6067 );
6068
6069 // A later mode selection must win for its own field and leave the other
6070 // two fields exactly as the earlier writes left them.
6071 assert_eq!(app.select_mode(AppMode::Operate), SettingSelection::Changed);
6072 app.startup_defaults.flush();
6073
6074 let final_settings = Settings::load().expect("reload");
6075 assert_eq!(final_settings.default_mode, "operate");
6076 assert_eq!(
6077 final_settings.default_model.as_deref(),
6078 Some(persisted_model.as_str()),
6079 "a mode write must not roll back the model"
6080 );
6081 assert_eq!(
6082 final_settings.reasoning_effort.as_deref(),
6083 Some(cycled_effort),
6084 "a mode write must not roll back the thinking level"
6085 );
6086 assert!(app.startup_defaults.drain_failures().is_empty());
6087 }
6088
6089 // ---------------------------------------------------------------------------
6090 // Startup defaults vs. the *other* settings writers
6091 // ---------------------------------------------------------------------------
6092 //
6093 // `StartupDefaultsWriter` only serializes the transactions it owns. The tests
6094 // above prove that much. What follows is the boundary the writer cannot provide
6095 // on its own: `settings.toml` has direct writers in the same process — most
6096 // sharply the Shift+Tab permission posture on the same event loop — and each of
6097 // them loads the whole file, changes some fields, and writes the whole file
6098 // back. Two such writers that do not share a load/modify/save lock each write
6099 // back the other's pre-image, and whichever saves last silently reverts the
6100 // other's field. That boundary now lives in `Settings::transact`.
6101
6102 /// Seal the settings file onto `tmp` via the config-path override, and hand back
6103 /// the root config path the posture writers need. Caller must already hold
6104 /// `lock_test_env()`.
6105 fn sealed_settings_with_root_config(
6106 tmp: &std::path::Path,
6107 ) -> (std::path::PathBuf, Vec<EnvVarGuard>) {
6108 let config_path = tmp.join("config.toml");
6109 let guards = vec![
6110 EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path),
6111 EnvVarGuard::remove("CODEWHALE_CONFIG_PATH"),
6112 EnvVarGuard::remove("DEEPSEEK_APPROVAL_POLICY"),
6113 ];
6114 (config_path, guards)
6115 }
6116
6117 /// Tab (queued mode write) and Shift+Tab (synchronous posture write) hit the
6118 /// same file through different writers. Neither may lose the other's field.
6119 ///
6120 /// This is the concrete pair from the v0.9.1 report: mode cycling spawns a
6121 /// background `default_mode` transaction, the very next keystroke persists
6122 /// `permission_posture` inline, and before `Settings::transact` the two loaded
6123 /// the same bytes — so the later save reverted whichever field the earlier one
6124 /// had just written.
6125 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
6126 async fn mode_and_permission_posture_writes_do_not_clobber_each_other() {
6127 let _lock = lock_test_env();
6128 let tmp = tempfile::TempDir::new().expect("tempdir");
6129 let (config_path, _env) = sealed_settings_with_root_config(tmp.path());
6130 let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
6131
6132 let mut options = test_options(false);
6133 options.start_in_agent_mode = true;
6134 options.config_path = Some(config_path);
6135 let mut app = App::new(options, &Config::default());
6136 app.approval_mode = ApprovalMode::Suggest;
6137 app.mode = AppMode::Agent;
6138
6139 // Alternate the two writers faster than a human can press keys. Plan is
6140 // skipped because it refuses permission changes by design (#3386), so every
6141 // iteration below genuinely performs both writes.
6142 for next_mode in [
6143 AppMode::Operate,
6144 AppMode::Agent,
6145 AppMode::Operate,
6146 AppMode::Agent,
6147 AppMode::Operate,
6148 ] {
6149 assert_eq!(
6150 app.select_mode(next_mode),
6151 SettingSelection::Changed,
6152 "mode selection must change mode"
6153 );
6154 assert!(
6155 app.cycle_approval_posture(),
6156 "the posture write must succeed, or the assertion below is vacuous"
6157 );
6158 }
6159 app.startup_defaults.flush();
6160
6161 let expected_posture = App::approval_posture_setting(app.mode_prefs.agent_approval_mode);
6162 let saved = Settings::load_persisted().expect("reload settings");
6163 assert_eq!(
6164 saved.default_mode, "operate",
6165 "the posture writer must not revert the mode the user cycled into"
6166 );
6167 assert_eq!(
6168 saved.permission_posture.as_deref(),
6169 Some(expected_posture),
6170 "the mode writer must not revert the posture the user cycled into"
6171 );
6172 assert!(
6173 app.startup_defaults.drain_failures().is_empty(),
6174 "no write in the burst may fail"
6175 );
6176 }
6177
6178 /// The same boundary for the thinking write against an unrelated direct writer.
6179 ///
6180 /// `Settings::transact` here stands in for every load/modify/save site that is
6181 /// not the startup-defaults writer — `/set --save`, the sidebar and work-surface
6182 /// size persists, the preset apply, the pin reorder. They all share one lock now,
6183 /// so a queued thinking write and an unrelated key cannot revert each other.
6184 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
6185 async fn thinking_and_an_unrelated_direct_setting_write_do_not_clobber_each_other() {
6186 let _lock = lock_test_env();
6187 let tmp = tempfile::TempDir::new().expect("tempdir");
6188 let _env = sealed_settings_home(tmp.path());
6189 let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
6190
6191 let mut app = App::new(test_options(false), &Config::default());
6192 app.api_provider = ApiProvider::Deepseek;
6193 app.auto_model = false;
6194 app.reasoning_effort = ReasoningEffort::Off;
6195
6196 for index in 0..6 {
6197 app.apply_reasoning_effort_cycle();
6198 // Interleaved on the same thread, exactly as the event loop would when a
6199 // `/set --save` or a divider drag lands between two Ctrl+T presses.
6200 Settings::transact(|settings| settings.set("max_history", &(100 + index).to_string()))
6201 .expect("the direct write must land");
6202 }
6203 app.startup_defaults.flush();
6204
6205 let expected_effort = app.reasoning_effort.as_setting_for_route(
6206 app.api_provider,
6207 &app.active_route_base_url,
6208 &app.model,
6209 );
6210 let saved = Settings::load_persisted().expect("reload settings");
6211 assert_eq!(
6212 saved.reasoning_effort.as_deref(),
6213 Some(expected_effort),
6214 "the direct writer must not revert the thinking level"
6215 );
6216 assert_eq!(
6217 saved.max_input_history, 105,
6218 "the thinking writer must not revert the last direct write"
6219 );
6220 assert!(app.startup_defaults.drain_failures().is_empty());
6221 }
6222
6223 /// Last write wins across *both* kinds of writer, and only for its own field.
6224 ///
6225 /// The startup-default writer decides ordering among its own queued
6226 /// transactions; `Settings::transact` decides atomicity against everything else.
6227 /// Together the final file must be the last value the user chose for every field
6228 /// they touched — not a mixture that depends on which blocking task the
6229 /// scheduler picked.
6230 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
6231 async fn rapid_mixed_writes_settle_on_the_last_value_for_every_field() {
6232 let _lock = lock_test_env();
6233 let tmp = tempfile::TempDir::new().expect("tempdir");
6234 let (config_path, _env) = sealed_settings_with_root_config(tmp.path());
6235 let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
6236
6237 let mut options = test_options(false);
6238 options.start_in_agent_mode = true;
6239 options.config_path = Some(config_path);
6240 let mut app = App::new(options, &Config::default());
6241 app.api_provider = ApiProvider::Deepseek;
6242 app.auto_model = false;
6243 app.reasoning_effort = ReasoningEffort::Off;
6244 app.approval_mode = ApprovalMode::Suggest;
6245 app.mode = AppMode::Agent;
6246
6247 for index in 0..5 {
6248 // Queued (background) writers.
6249 assert_eq!(
6250 app.select_mode(if index % 2 == 0 {
6251 AppMode::Operate
6252 } else {
6253 AppMode::Agent
6254 }),
6255 SettingSelection::Changed
6256 );
6257 app.apply_reasoning_effort_cycle();
6258 // Synchronous direct writers.
6259 assert!(app.cycle_approval_posture());
6260 Settings::transact(|settings| settings.set("max_history", &(200 + index).to_string()))
6261 .expect("the direct write must land");
6262 }
6263 // A model write goes through the synchronous startup-defaults path, which
6264 // must land behind everything queued before it.
6265 app.startup_defaults
6266 .apply_blocking(
6267 crate::tui::startup_defaults::StartupDefaults::default()
6268 .with_default_model("deepseek-chat"),
6269 )
6270 .expect("model write must land");
6271 app.startup_defaults.flush();
6272
6273 let expected_effort = app.reasoning_effort.as_setting_for_route(
6274 app.api_provider,
6275 &app.active_route_base_url,
6276 &app.model,
6277 );
6278 let expected_posture = App::approval_posture_setting(app.mode_prefs.agent_approval_mode);
6279 let saved = Settings::load_persisted().expect("reload settings");
6280 assert_eq!(saved.default_mode, app.mode.as_setting());
6281 assert_eq!(saved.reasoning_effort.as_deref(), Some(expected_effort));
6282 assert_eq!(saved.permission_posture.as_deref(), Some(expected_posture));
6283 assert_eq!(saved.max_input_history, 204);
6284 assert_eq!(saved.default_model.as_deref(), Some("deepseek-chat"));
6285 assert!(app.startup_defaults.drain_failures().is_empty());
6286 }
6287
6288 /// A test that never sealed its environment must not be able to write, and must
6289 /// not pay for another test's sealed scope.
6290 ///
6291 /// Almost every `App` test cycles modes without sealing `HOME`. Those calls have
6292 /// to be inert: not "usually inert because no other test happens to have opted
6293 /// in", but inert by construction, because the alternative is rewriting the
6294 /// developer's real `~/.codewhale/settings.toml` during `cargo test`.
6295 #[test]
6296 fn mode_cycling_in_an_unsealed_test_writes_nothing() {
6297 let mut app = App::new(test_options(false), &Config::default());
6298 app.mode = AppMode::Agent;
6299 assert_eq!(
6300 app.select_mode(AppMode::Operate),
6301 SettingSelection::Changed,
6302 "the live session must still change"
6303 );
6304 assert_eq!(app.mode, AppMode::Operate);
6305 assert_eq!(
6306 app.startup_defaults.pending_len(),
6307 0,
6308 "an unsealed test must enqueue nothing a later sealed drain could inherit"
6309 );
6310 assert!(
6311 app.startup_defaults.drain_failures().is_empty(),
6312 "a skipped test write is not a user-visible failure"
6313 );
6314 }
6315
6316 // ---------------------------------------------------------------------------
6317 // The live-route turn lock reaches the slash surfaces (#2982)
6318 // ---------------------------------------------------------------------------
6319 //
6320 // The lock used to live only in the selectors — Tab, Ctrl+T, the pickers, the
6321 // hotbar. `/set` and `/config <key> <value>` reached the same live route through
6322 // a different door, and both are reachable mid-turn: the composer accepts
6323 // Shift+Enter and the slash menu while `is_loading`. So during a running turn a
6324 // slash command could swap the model, thinking level, mode, or provider out from
6325 // under the engine *and* persist it. The refusal now sits in one place, above
6326 // every disk write and every `App` mutation.
6327
6328 /// Every live-route key and alias, exercised through the same entry point the
6329 /// slash commands use. Live state, persisted state, the startup-default queue,
6330 /// and setup progress must all be exactly where they started.
6331 #[test]
6332 fn slash_config_and_set_refuse_every_live_route_key_while_a_turn_runs() {
6333 let _lock = lock_test_env();
6334 let tmp = tempfile::TempDir::new().expect("tempdir");
6335 let _env = sealed_settings_home(tmp.path());
6336 let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
6337
6338 Settings::transact(|settings| {
6339 settings.default_mode = "plan".to_string();
6340 settings.default_model = Some("deepseek-chat".to_string());
6341 settings.reasoning_effort = Some("off".to_string());
6342 Ok(())
6343 })
6344 .expect("seed the persisted route");
6345 let before = Settings::load_persisted().expect("read the seeded settings");
6346
6347 let mut app = App::new(test_options(false), &Config::default());
6348 app.api_provider = ApiProvider::Deepseek;
6349 app.auto_model = false;
6350 app.set_model_selection("deepseek-chat".to_string());
6351 app.reasoning_effort = ReasoningEffort::Off;
6352 let _ = app.set_mode(AppMode::Plan);
6353 app.is_loading = true;
6354
6355 let live_mode = app.mode;
6356 let live_model = app.model.clone();
6357 let live_effort = app.reasoning_effort;
6358 let live_provider = app.api_provider;
6359
6360 // Both `--save` and session-only forms: the refusal is above the branch
6361 // that decides whether to persist, so neither may get through.
6362 for persist in [true, false] {
6363 for (key, value) in [
6364 ("model", "deepseek-v4-pro"),
6365 ("default_model", "deepseek-v4-pro"),
6366 ("reasoning_effort", "high"),
6367 ("effort", "high"),
6368 ("mode", "operate"),
6369 ("provider", "openai"),
6370 ] {
6371 let result = crate::commands::set_config_value(&mut app, key, value, persist);
6372 assert!(
6373 result.is_error,
6374 "/set {key} {value} (persist={persist}) must be refused mid-turn"
6375 );
6376 let message = result.message.unwrap_or_default();
6377 assert!(
6378 message.contains("locked while a turn is running"),
6379 "the refusal must say why, got {message:?}"
6380 );
6381 }
6382 }
6383
6384 assert_eq!(app.mode, live_mode, "live mode must not move");
6385 assert_eq!(app.model, live_model, "the live route model must not move");
6386 assert_eq!(
6387 app.reasoning_effort, live_effort,
6388 "the live thinking tier must not move"
6389 );
6390 assert_eq!(
6391 app.api_provider, live_provider,
6392 "the live provider must not move"
6393 );
6394
6395 let after = Settings::load_persisted().expect("reload settings");
6396 assert_eq!(after.default_mode, before.default_mode);
6397 assert_eq!(after.default_model, before.default_model);
6398 assert_eq!(after.reasoning_effort, before.reasoning_effort);
6399 assert_eq!(after.provider_models, before.provider_models);
6400
6401 assert_eq!(
6402 app.startup_defaults.pending_len(),
6403 0,
6404 "a refused command must not queue a startup-default write"
6405 );
6406 app.startup_defaults.flush();
6407 assert!(
6408 app.startup_defaults.drain_failures().is_empty(),
6409 "a refusal is not a write failure"
6410 );
6411 assert_eq!(
6412 Settings::load_persisted()
6413 .expect("reload after flush")
6414 .default_mode,
6415 before.default_mode,
6416 "nothing may land after the queue is drained either"
6417 );
6418 assert!(
6419 !codewhale_config::SetupState::path()
6420 .expect("setup state path")
6421 .exists(),
6422 "a refused route change must not record provider/model setup progress"
6423 );
6424 }
6425
6426 /// `default_mode` is a restart default that `set_config_value` deliberately does
6427 /// not apply to the live session, so the turn lock must leave it alone. Locking
6428 /// it would refuse a key that cannot affect the running turn.
6429 #[test]
6430 fn restart_only_default_mode_is_still_settable_while_a_turn_runs() {
6431 let _lock = lock_test_env();
6432 let tmp = tempfile::TempDir::new().expect("tempdir");
6433 let _env = sealed_settings_home(tmp.path());
6434 let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
6435
6436 let mut app = App::new(test_options(false), &Config::default());
6437 let _ = app.set_mode(AppMode::Plan);
6438 app.is_loading = true;
6439
6440 let result = crate::commands::set_config_value(&mut app, "default_mode", "operate", true);
6441 assert!(
6442 !result.is_error,
6443 "default_mode is restart-only, got {:?}",
6444 result.message
6445 );
6446 assert_eq!(
6447 Settings::load_persisted().expect("reload").default_mode,
6448 "operate"
6449 );
6450 assert_eq!(
6451 app.mode,
6452 AppMode::Plan,
6453 "a restart default must not move the live session"
6454 );
6455 }
6456
6457 // ---------------------------------------------------------------------------
6458 // Shutdown
6459 // ---------------------------------------------------------------------------
6460
6461 /// The last thing a user does before quitting is very often the selection they
6462 /// most want to keep. Those writes are queued off the event loop on purpose, so
6463 /// without an explicit join at shutdown the process can exit with the newest
6464 /// selection still sitting in the queue.
6465 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
6466 async fn shutdown_flushes_the_last_selection_and_returns_late_failures() {
6467 let _lock = lock_test_env();
6468 let tmp = tempfile::TempDir::new().expect("tempdir");
6469 let _env = sealed_settings_home(tmp.path());
6470 let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
6471
6472 let mut app = App::new(test_options(false), &Config::default());
6473 // Deliberately *not* flushed and never drained by an event-loop iteration:
6474 // this is the "Tab, then immediately quit" shape.
6475 assert_eq!(app.select_mode(AppMode::Operate), SettingSelection::Changed);
6476
6477 let failures = app.startup_defaults.shutdown();
6478 assert!(failures.is_empty(), "the write must land, not fail");
6479 assert_eq!(
6480 Settings::load_persisted().expect("reload").default_mode,
6481 "operate",
6482 "the last immediate selection must be on disk after shutdown"
6483 );
6484 }
6485
6486 /// A write that fails after the final redraw cannot be toasted — the toast
6487 /// surface will never be painted again. `shutdown` therefore *returns* the
6488 /// failures so the caller can print them on the restored terminal, and the
6489 /// message it produces is localized and path-free.
6490 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
6491 async fn a_late_startup_default_failure_is_returned_not_only_logged() {
6492 let _lock = lock_test_env();
6493 let tmp = tempfile::TempDir::new().expect("tempdir");
6494 // A regular file where the home directory must be: every settings write
6495 // below it fails.
6496 let blocked_home = tmp.path().join("codewhale-home-file");
6497 std::fs::write(&blocked_home, "not a directory").expect("blocking file");
6498 let _home = EnvVarGuard::set("HOME", tmp.path());
6499 let _user_profile = EnvVarGuard::set("USERPROFILE", tmp.path());
6500 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &blocked_home);
6501 let _deepseek_config = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
6502 let _codewhale_config = EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
6503 let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
6504
6505 let mut app = App::new(test_options(false), &Config::default());
6506 assert_eq!(app.select_mode(AppMode::Operate), SettingSelection::Changed);
6507
6508 let failures = app.startup_defaults.shutdown();
6509 let failure = failures
6510 .first()
6511 .expect("a failed write must be reported at shutdown, not swallowed");
6512 assert_eq!(
6513 failure.subjects,
6514 vec![crate::tui::startup_defaults::StartupDefaultSubject::Mode]
6515 );
6516
6517 let message = app.startup_default_failure_message(failure);
6518 assert!(
6519 message.contains("startup mode") && message.contains("was not saved"),
6520 "the shutdown notice must name what was lost, got {message:?}"
6521 );
6522 assert!(
6523 !message.contains(".codewhale") && !message.contains(tmp.path().to_str().unwrap()),
6524 "the shutdown notice must not print the settings path, got {message:?}"
6525 );
6526 }
6527
6528 // ---------------------------------------------------------------------------
6529 // Selector truth: refusal, live change, and persisted-same are three outcomes
6530 // ---------------------------------------------------------------------------
6531 //
6532 // `select_mode` used to return a bool. A refusal and an accepted same-live
6533 // selection both came back `false`, so `/mode`, the Alt+A/P/Y shortcuts, and the
6534 // hotbar mode rows all reported "Already in X mode." for both — including for
6535 // the case that had just rewritten the startup default.
6536
6537 /// The three outcomes are distinguishable, and only a live change is a live
6538 /// change.
6539 #[test]
6540 fn mode_selection_reports_refusal_change_and_persisted_same_distinctly() {
6541 let _lock = lock_test_env();
6542 let tmp = tempfile::TempDir::new().expect("tempdir");
6543 let _env = sealed_settings_home(tmp.path());
6544 let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
6545
6546 let mut app = App::new(test_options(false), &Config::default());
6547 let _ = app.set_mode(AppMode::Agent);
6548
6549 assert_eq!(app.select_mode(AppMode::Operate), SettingSelection::Changed);
6550 assert!(SettingSelection::Changed.changed_live_state());
6551 assert!(SettingSelection::Changed.accepted());
6552
6553 assert_eq!(
6554 app.select_mode(AppMode::Operate),
6555 SettingSelection::PersistedSame
6556 );
6557 assert!(
6558 !SettingSelection::PersistedSame.changed_live_state(),
6559 "a persisted-same selection must not resync the engine"
6560 );
6561 assert!(
6562 SettingSelection::PersistedSame.accepted(),
6563 "a persisted-same selection did write the startup default"
6564 );
6565
6566 app.is_loading = true;
6567 assert_eq!(app.select_mode(AppMode::Plan), SettingSelection::Refused);
6568 assert!(!SettingSelection::Refused.accepted());
6569 assert_eq!(app.mode, AppMode::Operate, "a refusal changes nothing");
6570 }
6571
6572 /// Every accepted same-live selection shows a saved receipt, and a refusal
6573 /// shows the lock message instead — the two must not read the same.
6574 #[test]
6575 fn slash_mode_distinguishes_a_saved_startup_default_from_a_refusal() {
6576 let _lock = lock_test_env();
6577 let tmp = tempfile::TempDir::new().expect("tempdir");
6578 let _env = sealed_settings_home(tmp.path());
6579 let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
6580
6581 let mut app = App::new(test_options(false), &Config::default());
6582 let _ = app.set_mode(AppMode::Operate);
6583 Settings::transact(|settings| {
6584 settings.default_mode = "agent".to_string();
6585 Ok(())
6586 })
6587 .expect("seed a startup default that disagrees with the live mode");
6588
6589 // Same live mode, different startup default: `/mode operate` is a real save.
6590 let receipt = crate::commands::switch_mode(&mut app, AppMode::Operate);
6591 assert!(
6592 receipt.contains("saved as startup default"),
6593 "the save must be reported, got {receipt:?}"
6594 );
6595 app.startup_defaults.flush();
6596 assert_eq!(
6597 Settings::load_persisted().expect("reload").default_mode,
6598 "operate"
6599 );
6600
6601 // Mid-turn the same command must be refused, and say so.
6602 app.is_loading = true;
6603 let refusal = crate::commands::switch_mode(&mut app, AppMode::Plan);
6604 assert!(
6605 refusal.contains("locked while a turn is running"),
6606 "a refusal must not read like a save, got {refusal:?}"
6607 );
6608 assert_ne!(refusal, receipt);
6609 }
6610
6611 /// The hotbar mode rows share the receipt: dispatching a row for the live mode
6612 /// is `Handled` (no engine resync) but still tells the user it saved.
6613 #[test]
6614 fn hotbar_mode_row_for_the_live_mode_still_shows_the_saved_receipt() {
6615 let _lock = lock_test_env();
6616 let tmp = tempfile::TempDir::new().expect("tempdir");
6617 let _env = sealed_settings_home(tmp.path());
6618 let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
6619
6620 let mut app = App::new(test_options(false), &Config::default());
6621 let _ = app.set_mode(AppMode::Plan);
6622 let outcome = app.select_mode(AppMode::Plan);
6623 app.report_mode_selection(AppMode::Plan, outcome);
6624
6625 assert_eq!(outcome, SettingSelection::PersistedSame);
6626 assert!(
6627 app.status_message
6628 .as_deref()
6629 .is_some_and(|message| message.contains("saved as startup default")),
6630 "got {:?}",
6631 app.status_message
6632 );
6633 app.startup_defaults.flush();
6634 assert_eq!(
6635 Settings::load_persisted().expect("reload").default_mode,
6636 "plan"
6637 );
6638 }
6639
6640 /// v0.9.1 kimi-k3 dogfood report: `settings.toml`'s `[provider_models]` is a memory of the last
6641 /// `/model` pick, so it must not override a model the user named for *this*
6642 /// launch. A dogfood user ran `codewhale --provider moonshot --model kimi-k3`
6643 /// and the session header kept showing the remembered `kimi-k2.7-code` while
6644 /// `doctor` reported `kimi-k3`; header and route have to agree.
6645 #[test]
6646 fn an_explicit_launch_model_outranks_the_remembered_provider_model() {
6647 let _lock = lock_test_env();
6648 let temp = tempfile::tempdir().expect("sealed state root");
6649 let config_path = temp.path().join("config.toml");
6650 std::fs::write(
6651 &config_path,
6652 "provider = \"moonshot\"\n\n[providers.moonshot]\napi_key = \"k\"\nmodel = \"kimi-k3\"\n",
6653 )
6654 .expect("seed config");
6655 std::fs::write(
6656 temp.path().join("settings.toml"),
6657 "[provider_models]\nmoonshot = \"kimi-k2.7-code\"\n",
6658 )
6659 .expect("seed settings");
6660 let _config_path_guard = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
6661 let _codewhale_config_path = EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
6662
6663 let config = Config::load(Some(config_path.clone()), None).expect("load sealed config");
6664
6665 // Without an explicit request this launch, the remembered pick still wins:
6666 // that stickiness is what `/model` exists for.
6667 let _no_flag = EnvVarGuard::remove("CODEWHALE_MODEL");
6668 let _no_legacy_flag = EnvVarGuard::remove("DEEPSEEK_MODEL");
6669 let remembered = App::new(
6670 TuiOptions {
6671 model: config.default_model(),
6672 ..test_options(false)
6673 },
6674 &config,
6675 );
6676 assert_eq!(
6677 remembered.model, "kimi-k2.7-code",
6678 "the remembered /model pick remains the default when nothing was named"
6679 );
6680
6681 // `--model` reaches this binary as CODEWHALE_MODEL. It must win.
6682 let _model_flag = EnvVarGuard::set("CODEWHALE_MODEL", "kimi-k3");
6683 let requested = App::new(
6684 TuiOptions {
6685 model: config.default_model(),
6686 ..test_options(false)
6687 },
6688 &config,
6689 );
6690 assert_eq!(
6691 requested.model, "kimi-k3",
6692 "an explicit --model must never be silently replaced by session memory"
6693 );
6694 }
6695
6696 #[test]
6697 fn ambient_clock_advances_by_clamped_steps() {
6698 let mut app = App::new(test_options(false), &Config::default());
6699 // First sample establishes the baseline without advancing.
6700 assert_eq!(app.sample_ambient_clock_ms(), 0);
6701 // Simulate a long gap between draws (a burst of stream work): the clock
6702 // may advance by at most one clamped step, so positions derived from it
6703 // cannot teleport across the gap.
6704 app.ambient_clock_sampled_at = Some(Instant::now() - Duration::from_secs(9));
6705 let advanced = app.sample_ambient_clock_ms();
6706 assert!(
6707 advanced <= App::AMBIENT_MAX_STEP_MS,
6708 "a 9s draw gap must clamp to one step, got {advanced}ms"
6709 );
6710 }
6711
6712 #[test]
6713 fn ambient_idle_settles_after_grace_and_wakes_on_activity() {
6714 let mut app = App::new(test_options(false), &Config::default());
6715 let start = Instant::now();
6716 // Fresh idle: not yet settled, anchor recorded.
6717 assert!(!app.ambient_idle_settled(false, start));
6718 // Still inside the grace window.
6719 assert!(!app.ambient_idle_settled(
6720 false,
6721 start + Duration::from_millis(App::AMBIENT_IDLE_SETTLE_MS - 500)
6722 ));
6723 // Past the grace window: the aquarium is still.
6724 assert!(app.ambient_idle_settled(
6725 false,
6726 start + Duration::from_millis(App::AMBIENT_IDLE_SETTLE_MS + 500)
6727 ));
6728 // Any live activity clears the anchor and wakes the scene…
6729 assert!(!app.ambient_idle_settled(true, start + Duration::from_secs(60)));
6730 // …and idleness afterwards restarts the full grace period.
6731 assert!(!app.ambient_idle_settled(false, start + Duration::from_secs(61)));
6732 }
6733
6734 #[test]
6735 fn launch_onboarding_skips_picker_when_xai_oauth_needs_reauth() {
6736 // #5032: an onboarded user whose active xAI OAuth credential is missing
6737 // must NOT be sent back to the generic provider picker every launch.
6738 let (onboarding, recovery) = launch_onboarding_decision(
6739 false, // skip_onboarding
6740 true, // was_onboarded
6741 true, // needs_api_key
6742 false, // needs_workspace_trust
6743 true, // xai_oauth_needs_reauth
6744 );
6745 assert_eq!(onboarding, OnboardingState::None);
6746 assert!(!recovery);
6747 }
6748
6749 #[test]
6750 fn launch_onboarding_opens_picker_for_generic_missing_key() {
6751 // A generic missing key (not the xAI-OAuth re-auth case) still reopens the
6752 // provider picker for recovery.
6753 let (onboarding, recovery) = launch_onboarding_decision(false, true, true, false, false);
6754 assert_eq!(onboarding, OnboardingState::Provider);
6755 assert!(recovery);
6756 }
6757
6758 #[test]
6759 fn launch_onboarding_clean_when_onboarded_with_key() {
6760 let (onboarding, recovery) = launch_onboarding_decision(false, true, false, false, false);
6761 assert_eq!(onboarding, OnboardingState::None);
6762 assert!(!recovery);
6763 }
6764
6765 #[test]
6766 fn launch_onboarding_keeps_first_run_when_not_onboarded_even_if_xai_reauth() {
6767 // A NOT-yet-onboarded user still gets first-run onboarding even if their
6768 // xAI OAuth credential is missing — the picker suppression is only for the
6769 // onboarded missing-key-RECOVERY case, not first run.
6770 let (onboarding, recovery) = launch_onboarding_decision(false, false, true, false, true);
6771 assert_eq!(onboarding, OnboardingState::Welcome);
6772 assert!(!recovery);
6773 }
6774
6774 lines RUST