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