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