use super::*;
use crate::config::{ApiProvider, Config, ProviderConfig, ProvidersConfig};
use crate::settings::Settings;
use crate::test_support::{EnvVarGuard, lock_test_env};
use crate::tools::plan::{PlanItemArg, StepStatus, UpdatePlanArgs};
use crate::tools::todo::TodoStatus;
use crate::tui::clipboard::{ClipboardHandler, PastedImage};
use crate::tui::history::{GenericToolCell, HistoryCell, ToolCell, ToolStatus};
use crate::tui::motion::MotionMode;
fn test_options(yolo: bool) -> TuiOptions {
TuiOptions {
model: "test-model".to_string(),
allow_shell: yolo,
start_in_agent_mode: true,
skip_onboarding: false,
yolo,
..crate::test_support::test_tui_options(PathBuf::from("."))
}
}
#[test]
fn app_motion_policy_and_transcript_bridge_cover_every_settings_mode() {
let mut app = App::new(test_options(false), &Config::default());
app.constrained_frame_rate = false;
for (low_motion, fancy_animations, expected_mode, static_status) in [
(false, true, MotionMode::Full, false),
(true, true, MotionMode::Reduced, true),
(false, false, MotionMode::Still, true),
(true, false, MotionMode::Reduced, true),
] {
app.low_motion = low_motion;
app.fancy_animations = fancy_animations;
assert_eq!(app.motion_policy().mode(), expected_mode);
assert_eq!(app.effective_low_motion_for_status(), static_status);
let options = app.transcript_render_options();
assert_eq!(options.low_motion, static_status);
assert_eq!(options.motion_mode, expected_mode);
}
}
#[cfg(unix)]
fn create_dir_symlink(target: &std::path::Path, link: &std::path::Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(target, link)
}
#[cfg(windows)]
fn create_dir_symlink(target: &std::path::Path, link: &std::path::Path) -> std::io::Result<()> {
std::os::windows::fs::symlink_dir(target, link)
}
#[test]
fn feature_intro_is_silent_while_onboarding_is_in_progress() {
let mut app = App::new(test_options(false), &Config::default());
app.onboarding = OnboardingState::Welcome;
let before = app.history.len();
app.maybe_show_feature_intro();
assert_eq!(
app.history.len(),
before,
"must not nudge while onboarding is in progress"
);
}
#[test]
fn feature_intro_is_silent_when_auth_setup_is_incomplete() {
let mut app = App::new(test_options(false), &Config::default());
app.onboarding = OnboardingState::None;
app.onboarding_needs_api_key = true;
let before = app.history.len();
app.maybe_show_feature_intro();
assert_eq!(
app.history.len(),
before,
"must not show 'setup is ready' when API key / auth is missing"
);
}
#[test]
fn feature_intro_shows_once_persists_then_is_idempotent() {
let _env_lock = lock_test_env();
let tmp = std::env::temp_dir().join(format!("cw-feature-intro-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
let config_path = tmp.join("config.toml");
let _env = EnvVarGuard::set(
"DEEPSEEK_CONFIG_PATH",
config_path.to_string_lossy().as_ref(),
);
let _ = std::fs::remove_file(tmp.join("settings.toml"));
let mut app = App::new(test_options(false), &Config::default());
app.onboarding = OnboardingState::None;
app.onboarding_needs_api_key = false;
let before = app.history.len();
app.maybe_show_feature_intro();
assert_eq!(app.history.len(), before, "intro must not hide empty state");
assert!(
app.status_message
.as_deref()
.is_some_and(|message| message.contains("Fleet") && message.contains("/fleet setup"))
);
assert!(
Settings::load()
.expect("settings should load")
.feature_intro_shown,
"feature_intro_shown should be persisted"
);
app.maybe_show_feature_intro();
assert_eq!(
app.history.len(),
before,
"intro must not repeat once the flag is persisted"
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn initial_input_prefill_waits_for_manual_submit() {
let mut options = test_options(false);
options.initial_input = Some(InitialInput::Prefill("review this PR".to_string()));
let app = App::new(options, &Config::default());
assert_eq!(app.input, "review this PR");
assert_eq!(app.cursor_position, "review this PR".chars().count());
assert!(!app.auto_submit_initial_input);
}
#[test]
fn initial_input_submit_marks_startup_dispatch() {
let mut options = test_options(false);
options.initial_input = Some(InitialInput::Submit(
"阅读项目 and wait for instructions".to_string(),
));
let app = App::new(options, &Config::default());
assert_eq!(app.input, "阅读项目 and wait for instructions");
assert_eq!(
app.cursor_position,
"阅读项目 and wait for instructions".chars().count()
);
assert!(app.auto_submit_initial_input);
}
#[test]
fn composer_arrows_scroll_default_is_true_without_mouse_capture() {
assert!(default_composer_arrows_scroll_for_platform(false, false));
}
#[test]
fn composer_arrows_scroll_default_is_false_with_mouse_capture_on_non_windows() {
assert!(!default_composer_arrows_scroll_for_platform(true, false));
}
#[test]
fn composer_arrows_scroll_default_is_false_with_mouse_capture_on_windows() {
assert!(!default_composer_arrows_scroll_for_platform(true, true));
}
#[test]
fn composer_arrows_scroll_default_is_true_without_mouse_capture_on_windows() {
assert!(default_composer_arrows_scroll_for_platform(false, true));
}
#[test]
fn move_cursor_line_start_multiline() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "abc\ndef\nghi".to_string();
app.cursor_position = "abc\ndef\nghi".chars().count(); app.move_cursor_line_start();
assert_eq!(app.cursor_position, "abc\ndef\n".len()); }
#[test]
fn move_cursor_line_start_singleline() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "hello".to_string();
app.cursor_position = 3;
app.move_cursor_line_start();
assert_eq!(app.cursor_position, 0);
}
#[test]
fn move_cursor_line_end_multiline() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "abc\ndef\nghi".to_string();
app.cursor_position = 0; app.move_cursor_line_end();
assert_eq!(app.cursor_position, "abc".len()); }
#[test]
fn move_cursor_line_end_at_newline_stays_at_line_end() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "abc\ndef\nghi".to_string();
app.cursor_position = "abc".len(); app.move_cursor_line_end();
assert_eq!(app.cursor_position, "abc".len()); }
#[test]
fn move_cursor_line_end_last_line() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "abc\ndef".to_string();
app.cursor_position = "abc\n".len(); app.move_cursor_line_end();
assert_eq!(app.cursor_position, "abc\ndef".chars().count()); }
#[test]
fn move_cursor_line_start_already_at_start() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "abc\ndef".to_string();
app.cursor_position = "abc\n".len(); app.move_cursor_line_start();
assert_eq!(app.cursor_position, "abc\n".len()); }
#[test]
fn test_trust_mode_follows_yolo_on_startup() {
let _env_lock = lock_test_env();
let tmp = tempfile::tempdir().expect("tempdir");
let config_path = tmp.path().join("config.toml");
let _config_env = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
let mut options = test_options(true);
options.config_path = Some(config_path);
let app = App::new(options, &Config::default());
assert!(app.trust_mode);
}
#[test]
fn reasoning_effort_display_label_uses_codex_xhigh() {
assert_eq!(
ReasoningEffort::Off.display_label_for_provider(ApiProvider::OpenaiCodex),
"low"
);
assert_eq!(
ReasoningEffort::Medium.display_label_for_provider(ApiProvider::OpenaiCodex),
"medium"
);
assert_eq!(
ReasoningEffort::Max.display_label_for_provider(ApiProvider::OpenaiCodex),
"xhigh"
);
assert_eq!(
ReasoningEffort::Max.display_label_for_provider(ApiProvider::Deepseek),
"max"
);
assert_eq!(
ReasoningEffort::High.display_label_for_provider(ApiProvider::OpenaiCodex),
"high"
);
let mut app = App::new(test_options(false), &Config::default());
app.api_provider = ApiProvider::OpenaiCodex;
app.reasoning_effort = ReasoningEffort::Max;
app.auto_model = false;
assert_eq!(app.reasoning_effort_display_label(), "xhigh");
app.reasoning_effort = ReasoningEffort::Auto;
app.last_effective_reasoning_effort =
Some(EffectiveReasoningEffort::Tier(ReasoningEffort::Max));
assert_eq!(app.reasoning_effort_display_label(), "auto: xhigh");
}
#[test]
fn fixed_auto_reasoning_label_preserves_untiered_effective_receipt() {
let mut app = App::new(test_options(false), &Config::default());
app.api_provider = ApiProvider::Zai;
app.auto_model = false;
app.model = crate::config::ZAI_GLM_5_TURBO_MODEL.to_string();
app.active_route_base_url = crate::config::DEFAULT_ZAI_BASE_URL.to_string();
app.reasoning_effort = ReasoningEffort::Auto;
app.last_effective_reasoning_effort =
Some(EffectiveReasoningEffort::ThinkingEnabledGranularityUnavailable);
assert_eq!(
app.reasoning_effort_display_label(),
"auto→thinking enabled; granularity unavailable"
);
}
#[test]
fn cache_replay_keeps_untiered_reasoning_enabled() {
let mut app = App::new(test_options(false), &Config::default());
app.api_provider = ApiProvider::Zai;
app.auto_model = false;
app.model = crate::config::ZAI_GLM_5_TURBO_MODEL.to_string();
app.reasoning_effort = ReasoningEffort::Auto;
app.last_effective_reasoning_effort =
Some(EffectiveReasoningEffort::ThinkingEnabledGranularityUnavailable);
assert_eq!(
app.reasoning_effort_api_value_for_replay(
ApiProvider::Zai,
crate::config::DEFAULT_ZAI_BASE_URL,
crate::config::ZAI_GLM_5_TURBO_MODEL,
),
Some("high")
);
app.api_provider = ApiProvider::Minimax;
app.model = crate::config::DEFAULT_MINIMAX_MODEL.to_string();
assert_eq!(
app.reasoning_effort_api_value_for_replay(
ApiProvider::Minimax,
crate::config::DEFAULT_MINIMAX_BASE_URL,
crate::config::DEFAULT_MINIMAX_MODEL,
),
Some("high")
);
app.last_effective_reasoning_effort = Some(EffectiveReasoningEffort::Unavailable);
assert_eq!(
app.reasoning_effort_api_value_for_replay(
ApiProvider::Zai,
crate::config::DEFAULT_ZAI_BASE_URL,
crate::config::ZAI_GLM_5_TURBO_MODEL,
),
None
);
}
#[test]
fn cache_replay_normalizes_reasoning_against_the_concrete_auto_route() {
let mut app = App::new(test_options(false), &Config::default());
app.api_provider = ApiProvider::Deepseek;
app.model = "auto".to_string();
app.auto_model = true;
app.reasoning_effort = ReasoningEffort::Off;
assert_eq!(
app.reasoning_effort_api_value_for_replay(
ApiProvider::OpenaiCodex,
crate::config::DEFAULT_OPENAI_CODEX_BASE_URL,
crate::config::DEFAULT_OPENAI_CODEX_MODEL,
),
Some("low"),
"Codex must apply its Off-to-Low floor even when DeepSeek is configured"
);
app.reasoning_effort = ReasoningEffort::Medium;
assert_eq!(
app.reasoning_effort_api_value_for_replay(
ApiProvider::Moonshot,
crate::config::DEFAULT_KIMI_CODE_BASE_URL,
crate::config::KIMI_CODE_K3_MODEL,
),
Some("medium"),
"Kimi Code K3 must retain its exact-route Medium tier"
);
}
#[test]
fn cache_replay_target_uses_the_last_completed_auto_route() {
let mut app = App::new(test_options(false), &Config::default());
app.model = "auto".to_string();
app.auto_model = true;
app.last_effective_provider = Some(ApiProvider::OpenaiCodex);
app.last_effective_provider_identity = Some(ApiProvider::OpenaiCodex.as_str().to_string());
app.last_effective_model = Some(crate::config::DEFAULT_OPENAI_CODEX_MODEL.to_string());
app.session.last_base_url = Some(crate::config::DEFAULT_OPENAI_CODEX_BASE_URL.to_string());
app.push_turn_cache_record(TurnCacheRecord {
provider: Some(ApiProvider::OpenaiCodex),
provider_identity: Some(ApiProvider::OpenaiCodex.as_str().to_string()),
model: Some(crate::config::DEFAULT_OPENAI_CODEX_MODEL.to_string()),
auto_model: true,
input_tokens: 1,
output_tokens: 1,
cache_hit_tokens: None,
cache_miss_tokens: None,
cache_write_tokens: None,
reasoning_tokens: None,
cost_audit: None,
reasoning_replay_tokens: None,
recorded_at: std::time::Instant::now(),
});
let target = app
.cache_replay_target()
.expect("completed Auto route must be replayable");
assert_eq!(target.provider, ApiProvider::OpenaiCodex);
assert_eq!(target.provider_identity, ApiProvider::OpenaiCodex.as_str());
assert_eq!(
target.provider_id.as_deref(),
Some(ApiProvider::OpenaiCodex.as_str())
);
assert_eq!(target.model, crate::config::DEFAULT_OPENAI_CODEX_MODEL);
assert_eq!(
target.base_url.as_deref(),
Some(crate::config::DEFAULT_OPENAI_CODEX_BASE_URL)
);
app.session.turn_cache_history.clear();
app.session.last_base_url = None;
app.session.last_warmup_key = Some(CacheWarmupKey {
provider: ApiProvider::OpenaiCodex.as_str().to_string(),
model: crate::config::DEFAULT_OPENAI_CODEX_MODEL.to_string(),
base_url: crate::config::DEFAULT_OPENAI_CODEX_BASE_URL.to_string(),
static_prefix_hash: "static".to_string(),
tool_catalog_hash: "tools".to_string(),
project_pack_hash: "project".to_string(),
skills_hash: "skills".to_string(),
});
assert_eq!(
app.cache_replay_target()
.and_then(|target| target.base_url)
.as_deref(),
Some(crate::config::DEFAULT_OPENAI_CODEX_BASE_URL)
);
}
#[test]
fn auto_reasoning_change_invalidates_the_previous_route_and_receipt() {
let mut app = App::new(test_options(false), &Config::default());
app.api_provider = ApiProvider::Deepseek;
app.model = "auto".to_string();
app.auto_model = true;
app.reasoning_effort = ReasoningEffort::Low;
app.reasoning_effort_preference = Some(ReasoningEffort::Low);
app.last_effective_provider = Some(ApiProvider::OpenaiCodex);
app.last_effective_provider_identity = Some(ApiProvider::OpenaiCodex.as_str().to_string());
app.last_effective_model = Some(crate::config::DEFAULT_OPENAI_CODEX_MODEL.to_string());
app.last_auto_route_receipt = Some(crate::model_routing::AutoRouteReceipt {
tier: crate::model_routing::AutoRouteTier::Strong,
pair: crate::model_routing::AutoRoutePair {
strong: crate::config::DEFAULT_OPENAI_CODEX_MODEL.to_string(),
fast: None,
},
scope: crate::model_routing::AutoRouteScope::ResolvedProvider,
data_path: crate::model_routing::AutoRouteDataPath::LocalHeuristic,
reason: crate::model_routing::AutoRouteReason::LocalHeuristic(
crate::model_routing::AutoRouteHeuristicReason::ComplexRequest,
),
});
app.last_effective_reasoning_effort =
Some(EffectiveReasoningEffort::Tier(ReasoningEffort::Max));
assert!(
app.cache_replay_target().is_some(),
"the completed route is replayable before its classifier input changes"
);
app.cycle_effort();
assert_eq!(app.reasoning_effort, ReasoningEffort::Medium);
assert_eq!(
app.status_message.as_deref(),
Some("Reasoning effort: med"),
"the change must describe the new unresolved request, not the old Codex receipt"
);
assert_eq!(app.last_effective_reasoning_effort, None);
assert_eq!(app.last_effective_provider, None);
assert_eq!(app.last_effective_provider_identity, None);
assert_eq!(app.last_effective_model, None);
assert_eq!(app.last_auto_route_receipt, None);
assert!(
app.cache_replay_target().is_none(),
"cache replay must wait for a route accepted under the new reasoning request"
);
let work = app
.work_state_snapshot()
.expect("Work snapshot")
.expect("effort activity creates graph state");
let crate::work_graph::WorkActivityEvent::ReasoningEffortChanged { effective, .. } = work
.graph
.expect("Work Graph")
.activities
.last()
.cloned()
.expect("effort activity");
assert_eq!(
effective,
crate::work_graph::ReasoningEffortTier::Medium,
"the activity receipt must not reuse the previous turn's effective tier"
);
}
#[test]
fn mode_and_thinking_are_locked_while_a_turn_is_running() {
let mut app = App::new(test_options(false), &Config::default());
app.mode = AppMode::Agent;
app.reasoning_effort = ReasoningEffort::Max;
app.is_loading = true;
app.cycle_mode();
assert_eq!(app.mode, AppMode::Agent, "mode must not change while busy");
assert!(
app.status_message
.as_deref()
.unwrap_or_default()
.contains("locked"),
"expected a 'locked' status message, got {:?}",
app.status_message
);
let before_effort = app.reasoning_effort;
app.cycle_effort();
assert_eq!(
app.reasoning_effort, before_effort,
"thinking must not change while busy"
);
app.is_loading = false;
app.cycle_mode();
assert_ne!(app.mode, AppMode::Agent, "mode should change when idle");
}
#[test]
fn cycle_effort_updates_effort_status_and_compaction() {
let mut app = App::new(test_options(false), &Config::default());
app.api_provider = ApiProvider::Deepseek;
app.auto_model = false;
app.reasoning_effort = ReasoningEffort::Off;
app.compact_threshold = 0;
app.cycle_effort();
assert_eq!(app.reasoning_effort, ReasoningEffort::Low);
assert_eq!(app.reasoning_effort_preference, Some(ReasoningEffort::Low));
assert_eq!(
app.status_message.as_deref(),
Some("Reasoning effort: low"),
"Ctrl+T must give visible feedback like the hotbar action"
);
assert_ne!(
app.compact_threshold, 0,
"cycling effort must refresh the compaction budget"
);
assert!(app.needs_redraw);
let work = app
.work_state_snapshot()
.expect("Work snapshot")
.expect("effort activity creates graph state");
let graph = work.graph.expect("Work Graph");
let activity = graph.activities.last().expect("effort activity");
match activity {
crate::work_graph::WorkActivityEvent::ReasoningEffortChanged {
requested,
effective,
provider_kind,
provider,
operation,
..
} => {
assert_eq!(*requested, crate::work_graph::ReasoningEffortTier::Low);
assert_eq!(*effective, crate::work_graph::ReasoningEffortTier::Low);
assert_eq!(*provider_kind, Some(ApiProvider::Deepseek));
assert_eq!(provider, "deepseek");
assert!(operation.is_none());
}
}
let wire = serde_json::to_value(activity).expect("serialize activity");
assert_eq!(wire["kind"], "reasoning_effort_changed");
assert!(
wire.get("text").is_none(),
"activity must not carry reasoning text"
);
}
#[test]
fn glm_5_turbo_records_enabled_with_granularity_unavailable() {
let mut app = App::new(test_options(false), &Config::default());
app.api_provider = ApiProvider::Zai;
app.auto_model = false;
app.active_route_base_url = crate::config::DEFAULT_ZAI_BASE_URL.to_string();
app.model = crate::config::ZAI_GLM_5_TURBO_MODEL.to_string();
app.reasoning_effort = ReasoningEffort::High;
app.cycle_effort();
assert_eq!(app.reasoning_effort, ReasoningEffort::Max);
assert_eq!(
app.status_message.as_deref(),
Some("Reasoning effort: max→thinking enabled; granularity unavailable")
);
assert_eq!(
app.reasoning_effort_display_label(),
"max→thinking enabled; granularity unavailable"
);
let work = app
.work_state_snapshot()
.expect("Work snapshot")
.expect("effort activity creates graph state");
let activity = work
.graph
.expect("Work Graph")
.activities
.last()
.cloned()
.expect("effort activity");
let crate::work_graph::WorkActivityEvent::ReasoningEffortChanged {
requested,
effective,
provider,
..
} = &activity;
assert_eq!(*requested, crate::work_graph::ReasoningEffortTier::Max);
assert_eq!(
*effective,
crate::work_graph::ReasoningEffortTier::ThinkingEnabledGranularityUnavailable
);
assert_eq!(provider, "zai");
assert_eq!(
serde_json::to_value(activity).expect("serialize activity")["effective"],
"thinking_enabled_granularity_unavailable"
);
}
#[test]
fn glm_5_1_records_enabled_with_granularity_unavailable() {
let mut app = App::new(test_options(false), &Config::default());
app.api_provider = ApiProvider::Zai;
app.auto_model = false;
app.active_route_base_url = crate::config::DEFAULT_ZAI_BASE_URL.to_string();
app.model = crate::config::ZAI_GLM_5_1_MODEL.to_string();
app.reasoning_effort = ReasoningEffort::High;
app.cycle_effort();
assert_eq!(
app.reasoning_effort_display_label(),
"max→thinking enabled; granularity unavailable"
);
let work = app
.work_state_snapshot()
.expect("Work snapshot")
.expect("effort activity creates graph state");
let crate::work_graph::WorkActivityEvent::ReasoningEffortChanged { effective, .. } = work
.graph
.expect("Work Graph")
.activities
.last()
.cloned()
.expect("effort activity");
assert_eq!(
effective,
crate::work_graph::ReasoningEffortTier::ThinkingEnabledGranularityUnavailable
);
}
#[test]
fn unknown_model_on_exact_zai_endpoint_records_effective_unavailable() {
let mut app = App::new(test_options(false), &Config::default());
app.api_provider = ApiProvider::Zai;
app.auto_model = false;
app.active_route_base_url = crate::config::DEFAULT_ZAI_BASE_URL.to_string();
app.model = "glm-future-unknown".to_string();
app.reasoning_effort = ReasoningEffort::High;
app.cycle_effort();
assert_eq!(
app.reasoning_effort_display_label(),
"max→effective unavailable"
);
let work = app
.work_state_snapshot()
.expect("Work snapshot")
.expect("effort activity creates graph state");
let crate::work_graph::WorkActivityEvent::ReasoningEffortChanged { effective, .. } = work
.graph
.expect("Work Graph")
.activities
.last()
.cloned()
.expect("effort activity");
assert_eq!(
effective,
crate::work_graph::ReasoningEffortTier::Unavailable
);
}
#[test]
fn compatible_zai_gateway_records_effective_unavailable() {
let mut app = App::new(test_options(false), &Config::default());
app.api_provider = ApiProvider::Zai;
app.auto_model = false;
app.active_route_base_url = "https://gateway.example/v1".to_string();
app.model = crate::config::ZAI_GLM_5_2_MODEL.to_string();
app.reasoning_effort = ReasoningEffort::High;
app.cycle_effort();
assert_eq!(app.reasoning_effort, ReasoningEffort::Max);
assert_eq!(
app.status_message.as_deref(),
Some("Reasoning effort: max→effective unavailable")
);
assert_eq!(
app.reasoning_effort_display_label(),
"max→effective unavailable"
);
let work = app
.work_state_snapshot()
.expect("Work snapshot")
.expect("effort activity creates graph state");
let activity = work
.graph
.expect("Work Graph")
.activities
.last()
.cloned()
.expect("effort activity");
let crate::work_graph::WorkActivityEvent::ReasoningEffortChanged {
requested,
effective,
provider,
..
} = &activity;
assert_eq!(*requested, crate::work_graph::ReasoningEffortTier::Max);
assert_eq!(
*effective,
crate::work_graph::ReasoningEffortTier::Unavailable
);
assert_eq!(provider, "zai");
assert_eq!(
serde_json::to_value(activity).expect("serialize activity")["effective"],
"unavailable"
);
}
#[test]
fn minimax_m3_high_and_max_receipts_do_not_claim_tier_granularity() {
for (previous, requested, label) in [
(ReasoningEffort::Off, ReasoningEffort::Auto, "auto"),
(ReasoningEffort::Auto, ReasoningEffort::Off, "off"),
] {
let mut app = App::new(test_options(false), &Config::default());
app.api_provider = ApiProvider::Minimax;
app.auto_model = false;
app.active_route_base_url = crate::config::DEFAULT_MINIMAX_BASE_URL.to_string();
app.model = crate::config::DEFAULT_MINIMAX_MODEL.to_string();
app.reasoning_effort = previous;
app.cycle_effort();
assert_eq!(app.reasoning_effort, requested);
assert_eq!(app.reasoning_effort_display_label(), label);
let work = app
.work_state_snapshot()
.expect("Work snapshot")
.expect("effort activity creates graph state");
let activity = work
.graph
.expect("Work Graph")
.activities
.last()
.cloned()
.expect("effort activity");
let crate::work_graph::WorkActivityEvent::ReasoningEffortChanged {
effective,
endpoint_identity,
model,
..
} = activity;
assert_eq!(
effective,
if requested == ReasoningEffort::Auto {
crate::work_graph::ReasoningEffortTier::Auto
} else {
crate::work_graph::ReasoningEffortTier::Off
}
);
assert_eq!(
endpoint_identity.as_deref(),
Some(crate::config::DEFAULT_MINIMAX_BASE_URL)
);
assert_eq!(model.as_deref(), Some(crate::config::DEFAULT_MINIMAX_MODEL));
}
}
#[test]
fn minimax_anthropic_m3_high_and_max_receipts_match_adaptive_wire_truth() {
for (previous, requested, label) in [
(ReasoningEffort::Off, ReasoningEffort::Auto, "auto"),
(ReasoningEffort::Auto, ReasoningEffort::Off, "off"),
] {
let mut app = App::new(test_options(false), &Config::default());
app.api_provider = ApiProvider::MinimaxAnthropic;
app.auto_model = false;
app.active_route_base_url = crate::config::DEFAULT_MINIMAX_ANTHROPIC_BASE_URL.to_string();
app.model = crate::config::DEFAULT_MINIMAX_MODEL.to_string();
app.reasoning_effort = previous;
app.cycle_effort();
assert_eq!(app.reasoning_effort, requested);
assert_eq!(app.reasoning_effort_display_label(), label);
let work = app
.work_state_snapshot()
.expect("Work snapshot")
.expect("effort activity creates graph state");
let activity = work
.graph
.expect("Work Graph")
.activities
.last()
.cloned()
.expect("effort activity");
let crate::work_graph::WorkActivityEvent::ReasoningEffortChanged {
effective,
provider_kind,
provider,
endpoint_identity,
model,
..
} = activity;
assert_eq!(
effective,
if requested == ReasoningEffort::Auto {
crate::work_graph::ReasoningEffortTier::Auto
} else {
crate::work_graph::ReasoningEffortTier::Off
}
);
assert_eq!(provider_kind, Some(ApiProvider::MinimaxAnthropic));
assert_eq!(provider, "minimax-anthropic");
assert_eq!(
endpoint_identity.as_deref(),
Some(crate::config::DEFAULT_MINIMAX_ANTHROPIC_BASE_URL)
);
assert_eq!(model.as_deref(), Some(crate::config::DEFAULT_MINIMAX_MODEL));
}
}
#[test]
fn named_custom_route_displays_and_persists_effective_unavailable() {
let mut app = App::new(test_options(false), &Config::default());
app.set_provider_identity(ApiProvider::Custom, "my-gateway");
app.auto_model = false;
app.active_route_base_url = "https://gateway.example/v1?api_key=must-not-persist".to_string();
app.model = "vendor-model-x".to_string();
app.reasoning_effort = ReasoningEffort::High;
app.cycle_effort();
assert_eq!(app.reasoning_effort, ReasoningEffort::Max);
assert_eq!(
app.reasoning_effort_display_label(),
"max→effective unavailable"
);
let work = app
.work_state_snapshot()
.expect("Work snapshot")
.expect("unknown route activity creates valid graph state");
let activity = work
.graph
.expect("Work Graph")
.activities
.last()
.cloned()
.expect("effort activity");
let crate::work_graph::WorkActivityEvent::ReasoningEffortChanged {
effective,
provider_kind,
provider,
endpoint_identity,
model,
..
} = activity;
assert_eq!(
effective,
crate::work_graph::ReasoningEffortTier::Unavailable
);
assert_eq!(provider_kind, Some(ApiProvider::Custom));
assert_eq!(provider, "my-gateway");
let endpoint = endpoint_identity.expect("redacted endpoint provenance");
assert!(endpoint.contains("gateway.example"), "{endpoint}");
assert!(!endpoint.contains("must-not-persist"), "{endpoint}");
assert_eq!(model.as_deref(), Some("vendor-model-x"));
}
#[test]
fn custom_routes_named_with_builtin_slugs_retain_custom_kind_and_fail_closed() {
for identity in ["openai", "zai"] {
let mut app = App::new(test_options(false), &Config::default());
app.set_provider_identity(ApiProvider::Custom, identity);
app.auto_model = false;
app.active_route_base_url = "https://gateway.example/v1".to_string();
app.model = "vendor-model-x".to_string();
app.reasoning_effort = ReasoningEffort::High;
app.cycle_effort();
assert_eq!(
app.reasoning_effort_display_label(),
"max→effective unavailable"
);
let work = app
.work_state_snapshot()
.expect("Work snapshot")
.expect("effort activity creates graph state");
let activity = work
.graph
.expect("Work Graph")
.activities
.last()
.cloned()
.expect("effort activity");
let crate::work_graph::WorkActivityEvent::ReasoningEffortChanged {
effective,
provider_kind,
provider,
..
} = activity;
assert_eq!(
effective,
crate::work_graph::ReasoningEffortTier::Unavailable
);
assert_eq!(provider_kind, Some(ApiProvider::Custom));
assert_eq!(provider, identity);
}
}
#[test]
fn zai_gateway_off_and_high_receipts_remain_unavailable() {
for (previous, requested, label) in [
(ReasoningEffort::High, ReasoningEffort::Max, "max"),
(ReasoningEffort::Max, ReasoningEffort::Auto, "auto"),
] {
let mut app = App::new(test_options(false), &Config::default());
app.api_provider = ApiProvider::Zai;
app.auto_model = false;
app.active_route_base_url = "https://gateway.example/v1".to_string();
app.model = crate::config::ZAI_GLM_5_2_MODEL.to_string();
app.reasoning_effort = previous;
app.cycle_effort();
assert_eq!(app.reasoning_effort, requested);
assert_eq!(
app.reasoning_effort_display_label(),
format!("{label}→effective unavailable")
);
}
}
#[test]
fn kimi_code_high_and_max_work_receipts_preserve_exact_tiers() {
for (previous, requested) in [
(ReasoningEffort::Off, ReasoningEffort::Low),
(ReasoningEffort::High, ReasoningEffort::Max),
] {
let mut app = App::new(test_options(false), &Config::default());
app.api_provider = ApiProvider::Moonshot;
app.auto_model = false;
app.active_route_base_url = crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string();
app.model = crate::config::KIMI_CODE_K3_MODEL.to_string();
app.reasoning_effort = previous;
app.cycle_effort();
let work = app
.work_state_snapshot()
.expect("Work snapshot")
.expect("effort activity creates graph state");
let activity = work
.graph
.expect("Work Graph")
.activities
.last()
.cloned()
.unwrap();
let crate::work_graph::WorkActivityEvent::ReasoningEffortChanged {
effective,
endpoint_identity,
model,
..
} = activity;
assert_eq!(effective, requested.into());
assert_eq!(
endpoint_identity.as_deref(),
Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL)
);
assert_eq!(model.as_deref(), Some(crate::config::KIMI_CODE_K3_MODEL));
}
}
#[test]
fn active_turn_zai_receipt_overrides_all_mutable_parallel_route_metadata() {
let mut app = App::new(test_options(false), &Config::default());
app.api_provider = ApiProvider::Deepseek;
app.active_route_base_url = crate::config::DEFAULT_DEEPSEEK_BASE_URL.to_string();
app.model = "deepseek-chat".to_string();
app.reasoning_effort = ReasoningEffort::High;
app.active_turn = Some(ActiveTurnMetadata {
turn_id: "turn-zai-receipt".to_string(),
created_at: chrono::Utc::now(),
route: Some(crate::core::events::TurnRoute {
provider: ApiProvider::Zai,
provider_identity: "openai".to_string(),
model: "mutable-wrong-model".to_string(),
auto_model: false,
receipt: Some(crate::route_receipt::TurnRouteReceipt::new(
ApiProvider::Zai,
"zai",
crate::config::ZAI_GLM_5_TURBO_MODEL,
crate::config::DEFAULT_ZAI_BASE_URL,
"test-secret-never-persisted",
)),
billing: Some(crate::core::events::RouteBillingEnvelope {
billing_surface: None,
endpoint_fingerprint: None,
billing_mode: crate::cost_status::RouteBillingMode::Unknown,
dispatched_at: chrono::Utc::now(),
}),
base_url: crate::config::DEFAULT_ZAI_BASE_URL.to_string(),
billing_product: crate::route_billing::RouteProduct::Unproven,
}),
auto_route_receipt: None,
suggestion_authority: None,
});
assert_eq!(
app.reasoning_effort_display_label(),
"high→thinking enabled; granularity unavailable"
);
app.apply_reasoning_effort_cycle();
let work = app
.work_state_snapshot()
.expect("Work snapshot")
.expect("effort activity creates graph state");
let activity = work
.graph
.expect("Work Graph")
.activities
.last()
.cloned()
.expect("effort activity");
let crate::work_graph::WorkActivityEvent::ReasoningEffortChanged {
provider_kind,
provider,
endpoint_identity,
model,
..
} = activity;
assert_eq!(provider_kind, Some(ApiProvider::Zai));
assert_eq!(provider, "zai");
assert_eq!(
endpoint_identity.as_deref(),
Some(crate::config::DEFAULT_ZAI_BASE_URL)
);
assert_eq!(model.as_deref(), Some(crate::config::ZAI_GLM_5_TURBO_MODEL));
}
#[test]
fn pending_zai_route_without_endpoint_receipt_is_effective_unavailable() {
let mut app = App::new(test_options(false), &Config::default());
app.api_provider = ApiProvider::Deepseek;
app.auto_model = false;
app.reasoning_effort = ReasoningEffort::Max;
app.pending_turn_route = Some((
ApiProvider::Zai,
crate::config::ZAI_GLM_5_2_MODEL.to_string(),
true,
));
assert_eq!(
app.reasoning_effort_display_label(),
"max→effective unavailable"
);
}
#[test]
fn reasoning_effort_display_receipts_route_normalization() {
let mut app = App::new(test_options(false), &Config::default());
app.api_provider = ApiProvider::Moonshot;
app.auto_model = false;
app.reasoning_effort = ReasoningEffort::Low;
app.active_route_base_url = crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string();
app.model = "kimi-k2.5".to_string();
assert_eq!(app.reasoning_effort_display_label(), "low→high");
app.active_route_base_url = crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string();
app.model = "k3".to_string();
assert_eq!(app.reasoning_effort_display_label(), "low");
app.reasoning_effort = ReasoningEffort::Off;
assert_eq!(app.reasoning_effort_display_label(), "off→low");
}
#[test]
fn reasoning_effort_api_values_are_provider_aware_for_codex() {
assert_eq!(
ReasoningEffort::Off.normalize_for_provider(ApiProvider::OpenaiCodex),
ReasoningEffort::Low
);
assert_eq!(
ReasoningEffort::Auto.normalize_for_provider(ApiProvider::OpenaiCodex),
ReasoningEffort::Medium
);
assert_eq!(
ReasoningEffort::Max.api_value_for_provider(ApiProvider::OpenaiCodex),
Some("xhigh")
);
assert_eq!(
ReasoningEffort::Off.api_value_for_provider(ApiProvider::OpenaiCodex),
Some("low")
);
assert_eq!(
ReasoningEffort::Max.api_value_for_provider(ApiProvider::Deepseek),
Some("max")
);
assert_eq!(
ReasoningEffort::from_setting("ultracode"),
ReasoningEffort::Ultra
);
}
#[test]
fn ollama_cloud_normal_turns_preserve_the_documented_reasoning_ladder() {
let base_url = crate::config::DEFAULT_OLLAMA_CLOUD_BASE_URL;
let model = crate::config::DEFAULT_OLLAMA_CLOUD_MODEL;
for effort in [
ReasoningEffort::Off,
ReasoningEffort::Low,
ReasoningEffort::Medium,
ReasoningEffort::High,
ReasoningEffort::Max,
] {
assert_eq!(
effort.normalize_for_route(ApiProvider::OllamaCloud, base_url, model),
effort,
"{effort:?} must remain distinct on Ollama's documented Cloud ladder"
);
}
assert_eq!(
ReasoningEffort::Minimal.normalize_for_route(ApiProvider::OllamaCloud, base_url, model,),
ReasoningEffort::Low
);
assert_eq!(
ReasoningEffort::XHigh.normalize_for_route(ApiProvider::OllamaCloud, base_url, model),
ReasoningEffort::Max
);
}
#[test]
fn reasoning_effort_uses_one_strict_alias_table_and_legacy_fallback() {
for raw in ["off", "none", "disabled", "false"] {
assert_eq!(ReasoningEffort::parse_strict(raw), Ok(ReasoningEffort::Off));
}
for raw in ["low", "minimum", "minimal", "light"] {
assert_eq!(ReasoningEffort::parse_strict(raw), Ok(ReasoningEffort::Low));
}
for raw in ["medium", "mid"] {
assert_eq!(
ReasoningEffort::parse_strict(raw),
Ok(ReasoningEffort::Medium)
);
}
assert_eq!(
ReasoningEffort::parse_strict("xhigh"),
Ok(ReasoningEffort::XHigh)
);
for raw in ["ultra", "ultracode"] {
assert_eq!(
ReasoningEffort::parse_strict(raw),
Ok(ReasoningEffort::Ultra)
);
}
for raw in ["max", "maximum"] {
assert_eq!(ReasoningEffort::parse_strict(raw), Ok(ReasoningEffort::Max));
}
assert!(ReasoningEffort::parse_strict("surprise").is_err());
assert_eq!(
ReasoningEffort::from_setting("surprise"),
ReasoningEffort::Max
);
}
#[test]
fn reasoning_effort_normalizes_each_exact_k3_route_without_neighbor_leakage() {
let kimi_base = crate::config::DEFAULT_KIMI_CODE_BASE_URL;
let moonshot_base = crate::config::DEFAULT_MOONSHOT_BASE_URL;
assert_eq!(
ReasoningEffort::Off.normalize_for_route(ApiProvider::Moonshot, kimi_base, "k3"),
ReasoningEffort::Low,
"membership K3 stays on K3 by mapping off to its lowest thinking tier"
);
assert_eq!(
ReasoningEffort::Auto.normalize_for_route(ApiProvider::Moonshot, kimi_base, "k3"),
ReasoningEffort::Auto,
"route normalization preserves the Auto sentinel until dispatch selects a concrete tier"
);
assert_eq!(
ReasoningEffort::Low.normalize_for_route(ApiProvider::Moonshot, kimi_base, "k3"),
ReasoningEffort::Low
);
assert_eq!(
ReasoningEffort::Medium.normalize_for_route(ApiProvider::Moonshot, kimi_base, "k3"),
ReasoningEffort::Medium
);
assert_eq!(
ReasoningEffort::Low.normalize_for_route(ApiProvider::Moonshot, moonshot_base, "k3"),
ReasoningEffort::High
);
assert_eq!(
ReasoningEffort::Medium.normalize_for_route(
ApiProvider::Moonshot,
kimi_base,
"kimi-for-coding",
),
ReasoningEffort::High
);
assert_eq!(
ReasoningEffort::Off.normalize_for_route(
ApiProvider::Moonshot,
moonshot_base,
crate::config::MOONSHOT_KIMI_K3_MODEL,
),
ReasoningEffort::Low,
"direct K3 is always-thinking, so off becomes its lowest supported tier"
);
assert_eq!(
ReasoningEffort::Low.normalize_for_route(
ApiProvider::Moonshot,
moonshot_base,
crate::config::MOONSHOT_KIMI_K3_MODEL,
),
ReasoningEffort::Low
);
assert_eq!(
ReasoningEffort::Medium.normalize_for_route(
ApiProvider::Moonshot,
moonshot_base,
crate::config::MOONSHOT_KIMI_K3_MODEL,
),
ReasoningEffort::High
);
assert_eq!(
ReasoningEffort::Off.normalize_for_route(
ApiProvider::Moonshot,
"https://proxy.example/v1",
crate::config::MOONSHOT_KIMI_K3_MODEL,
),
ReasoningEffort::Off,
"a neighboring gateway must not inherit direct-platform always-thinking semantics"
);
}
#[test]
fn picker_uses_catalog_reasoning_efforts_for_grok_46() {
let labels: Vec<&str> = crate::tui::model_picker::picker_efforts_for_route(
ApiProvider::Xai,
ApiProvider::Xai.default_base_url(),
crate::config::XAI_GROK_4_6_MODEL,
false,
)
.iter()
.map(|effort| effort.as_setting())
.collect();
assert_eq!(labels, vec!["auto", "low", "medium", "high", "xhigh"]);
}
#[test]
fn reasoning_effort_preserves_grok_46_ladder_only_on_exact_xai_route() {
let xai = crate::config::DEFAULT_XAI_BASE_URL;
let model = crate::config::XAI_GROK_4_6_MODEL;
for (requested, expected) in [
(ReasoningEffort::Off, ReasoningEffort::High),
(ReasoningEffort::Low, ReasoningEffort::Low),
(ReasoningEffort::Medium, ReasoningEffort::Medium),
(ReasoningEffort::High, ReasoningEffort::High),
(ReasoningEffort::XHigh, ReasoningEffort::XHigh),
(ReasoningEffort::Max, ReasoningEffort::XHigh),
(ReasoningEffort::Ultra, ReasoningEffort::XHigh),
(ReasoningEffort::Auto, ReasoningEffort::Auto),
] {
assert_eq!(
requested.normalize_for_route(ApiProvider::Xai, xai, model),
expected,
"{requested:?}"
);
}
assert_eq!(
ReasoningEffort::Medium.normalize_for_route(
ApiProvider::Xai,
"https://gateway.example/v1",
model,
),
ReasoningEffort::Medium,
"catalog effort lists are model metadata; the Chat wire still omits them on a custom endpoint"
);
}
fn xai_grok_46_startup_config() -> Config {
Config {
provider: Some("xai".to_string()),
providers: Some(ProvidersConfig {
xai: ProviderConfig {
api_key: Some("xai-startup-test-key".to_string()),
base_url: Some(crate::config::DEFAULT_XAI_BASE_URL.to_string()),
model: Some(crate::config::XAI_GROK_4_6_MODEL.to_string()),
..ProviderConfig::default()
},
..ProvidersConfig::default()
}),
..Config::default()
}
}
fn xai_grok_46_startup_app(config: &Config) -> App {
let mut options = test_options(false);
options.model = crate::config::XAI_GROK_4_6_MODEL.to_string();
App::new(options, config)
}
#[test]
fn app_new_uses_grok_46_official_high_when_effort_is_unset() {
let _lock = lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let config_path = tmp.path().join("config.toml");
let _config_path = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
let config = xai_grok_46_startup_config();
let app = xai_grok_46_startup_app(&config);
assert_eq!(app.api_provider, ApiProvider::Xai);
assert_eq!(app.model, crate::config::XAI_GROK_4_6_MODEL);
assert_eq!(
app.active_route_base_url,
crate::config::DEFAULT_XAI_BASE_URL
);
assert_eq!(app.reasoning_effort, ReasoningEffort::High);
assert_eq!(app.reasoning_effort_display_label(), "high");
}
#[test]
fn app_new_maps_persisted_grok_46_off_to_high_and_max_to_xhigh() {
let _lock = lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let config_path = tmp.path().join("config.toml");
let _config_path = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
let config = xai_grok_46_startup_config();
for (raw, expected, display) in [
("off", ReasoningEffort::High, "high"),
("max", ReasoningEffort::XHigh, "xhigh"),
("auto", ReasoningEffort::Auto, "auto"),
] {
std::fs::write(
tmp.path().join("settings.toml"),
format!("reasoning_effort = \"{raw}\"\n"),
)
.expect("settings");
let app = xai_grok_46_startup_app(&config);
assert_eq!(app.reasoning_effort, expected, "raw setting {raw}");
assert_eq!(app.reasoning_effort_display_label(), display);
}
}
#[test]
fn cycle_effort_walks_grok_46_official_ladder() {
let mut app = App::new(test_options(false), &Config::default());
app.api_provider = ApiProvider::Xai;
app.auto_model = false;
app.active_route_base_url = crate::config::DEFAULT_XAI_BASE_URL.to_string();
app.model = crate::config::XAI_GROK_4_6_MODEL.to_string();
app.reasoning_effort = ReasoningEffort::High;
let expected = [
ReasoningEffort::XHigh,
ReasoningEffort::Auto,
ReasoningEffort::Low,
ReasoningEffort::Medium,
ReasoningEffort::High,
];
for next in expected {
app.cycle_effort();
assert_eq!(app.reasoning_effort, next, "next {:?}", next);
}
}
#[test]
fn cycle_effort_walks_grok_45_official_ladder_without_xhigh() {
let mut app = App::new(test_options(false), &Config::default());
app.api_provider = ApiProvider::Xai;
app.auto_model = false;
app.active_route_base_url = crate::config::DEFAULT_XAI_BASE_URL.to_string();
app.model = crate::config::XAI_GROK_4_5_MODEL.to_string();
app.reasoning_effort = ReasoningEffort::High;
app.cycle_effort();
assert_eq!(app.reasoning_effort, ReasoningEffort::Auto);
app.cycle_effort();
assert_eq!(app.reasoning_effort, ReasoningEffort::Low);
app.cycle_effort();
assert_eq!(app.reasoning_effort, ReasoningEffort::Medium);
app.cycle_effort();
assert_eq!(app.reasoning_effort, ReasoningEffort::High);
}
#[test]
fn picker_uses_catalog_reasoning_efforts_for_grok_45() {
let labels: Vec<&str> = crate::tui::model_picker::picker_efforts_for_route(
ApiProvider::Xai,
ApiProvider::Xai.default_base_url(),
crate::config::XAI_GROK_4_5_MODEL,
false,
)
.iter()
.map(|effort| effort.as_setting())
.collect();
assert_eq!(labels, vec!["auto", "low", "medium", "high"]);
}
#[test]
fn set_model_selection_normalizes_codex_fixed_model_effort() {
let mut app = App::new(test_options(false), &Config::default());
app.api_provider = ApiProvider::OpenaiCodex;
app.reasoning_effort = ReasoningEffort::Off;
app.reasoning_effort_preference = Some(ReasoningEffort::Off);
app.set_model_selection("gpt-5.5-codex".to_string());
assert_eq!(app.reasoning_effort, ReasoningEffort::Low);
assert_eq!(app.reasoning_effort_preference, Some(ReasoningEffort::Off));
assert!(!app.auto_model);
assert_eq!(app.reasoning_effort_display_label(), "low");
}
#[test]
fn auto_model_selection_preserves_only_explicit_reasoning_effort() {
let mut app = App::new(test_options(false), &Config::default());
app.reasoning_effort = ReasoningEffort::Max;
app.reasoning_effort_preference = None;
app.set_model_selection("auto".to_string());
assert!(app.auto_model);
assert_eq!(app.reasoning_effort, ReasoningEffort::Auto);
assert_eq!(app.reasoning_effort_preference, None);
for (provider, requested, normalized) in [
(
ApiProvider::Deepseek,
ReasoningEffort::Low,
ReasoningEffort::High,
),
(
ApiProvider::OpenaiCodex,
ReasoningEffort::Off,
ReasoningEffort::Low,
),
] {
app.api_provider = provider;
app.auto_model = false;
app.model = "fixed-model".to_string();
app.reasoning_effort = normalized;
app.reasoning_effort_preference = Some(requested);
app.set_model_selection("auto".to_string());
assert_eq!(app.reasoning_effort, requested, "{provider:?}");
assert_eq!(
app.reasoning_effort_preference,
Some(requested),
"{provider:?}"
);
}
}
#[test]
fn app_new_normalizes_saved_codex_reasoning_effort() {
let _lock = lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let config_path = tmp.path().join("config.toml");
let _config_path = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
let _token = EnvVarGuard::set("OPENAI_CODEX_ACCESS_TOKEN", "test-codex-startup-token");
let config = Config {
provider: Some("openai-codex".to_string()),
providers: Some(ProvidersConfig {
openai_codex: ProviderConfig {
model: Some(crate::config::DEFAULT_OPENAI_CODEX_MODEL.to_string()),
..ProviderConfig::default()
},
..ProvidersConfig::default()
}),
..Config::default()
};
for (raw, expected, display) in [
("off", ReasoningEffort::Low, "low"),
("auto", ReasoningEffort::Medium, "medium"),
("max", ReasoningEffort::Max, "xhigh"),
] {
std::fs::write(
tmp.path().join("settings.toml"),
format!("reasoning_effort = \"{raw}\"\n"),
)
.expect("settings");
let app = App::new(test_options(false), &config);
assert_eq!(app.api_provider, ApiProvider::OpenaiCodex);
assert_eq!(app.reasoning_effort, expected, "raw setting {raw}");
assert_eq!(
app.reasoning_effort_preference,
Some(ReasoningEffort::from_setting(raw)),
"raw setting {raw}"
);
assert_eq!(app.reasoning_effort_display_label(), display);
}
}
#[test]
fn app_new_exposes_direct_moonshot_k3_off_as_effective_low() {
let _lock = lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let config_path = tmp.path().join("config.toml");
let _config_path = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
std::fs::write(
tmp.path().join("settings.toml"),
"reasoning_effort = \"off\"\n",
)
.expect("settings");
let config = Config {
provider: Some("moonshot".to_string()),
providers: Some(ProvidersConfig {
moonshot: ProviderConfig {
api_key: Some("moonshot-startup-test-key".to_string()),
base_url: Some(crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string()),
model: Some(crate::config::MOONSHOT_KIMI_K3_MODEL.to_string()),
..ProviderConfig::default()
},
..ProvidersConfig::default()
}),
..Config::default()
};
let mut options = test_options(false);
options.model = crate::config::MOONSHOT_KIMI_K3_MODEL.to_string();
let app = App::new(options, &config);
assert_eq!(app.api_provider, ApiProvider::Moonshot);
assert_eq!(app.model, crate::config::MOONSHOT_KIMI_K3_MODEL);
assert_eq!(
app.active_route_base_url,
crate::config::DEFAULT_MOONSHOT_BASE_URL
);
assert_eq!(app.reasoning_effort, ReasoningEffort::Low);
assert_eq!(app.reasoning_effort_display_label(), "low");
}
#[test]
fn codex_startup_threads_fresh_roster_context_into_active_route_limits() {
let _lock = lock_test_env();
let tmp = tempfile::tempdir().expect("tempdir");
let config_path = tmp.path().join("config.toml");
let codex_home = tmp.path().join("codex-home");
std::fs::create_dir_all(&codex_home).expect("Codex home");
std::fs::write(
codex_home.join("models_cache.json"),
serde_json::to_vec(&serde_json::json!({
"fetched_at": chrono::Utc::now(),
"models": [{
"slug": crate::config::DEFAULT_OPENAI_CODEX_MODEL,
"priority": 1,
"context_window": 128000,
"supported_reasoning_levels": [{"effort": "high"}]
}]
}))
.expect("serialize cache"),
)
.expect("write cache");
let _config_path = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
let _codex_home = EnvVarGuard::set("CODEX_HOME", &codex_home);
let _token = EnvVarGuard::set("OPENAI_CODEX_ACCESS_TOKEN", "test-codex-startup-token");
let config = Config {
provider: Some("openai-codex".to_string()),
providers: Some(ProvidersConfig {
openai_codex: ProviderConfig {
model: Some(crate::config::DEFAULT_OPENAI_CODEX_MODEL.to_string()),
..ProviderConfig::default()
},
..ProvidersConfig::default()
}),
..Config::default()
};
let mut options = test_options(false);
options.model = crate::config::DEFAULT_OPENAI_CODEX_MODEL.to_string();
let app = App::new(options, &config);
assert_eq!(app.api_provider, ApiProvider::OpenaiCodex);
assert_eq!(
app.active_route_limits
.and_then(|limits| limits.context_tokens),
Some(128_000)
);
assert_eq!(
crate::route_budget::route_context_window_tokens(
app.api_provider,
&app.model,
app.active_route_limits,
),
128_000
);
}
#[test]
fn settings_default_provider_auth_check_uses_provider_scoped_key() {
let _lock = lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let config_path = tmp.path().join("config.toml");
std::fs::write(
tmp.path().join("settings.toml"),
"default_provider = \"openai\"\n",
)
.expect("settings");
let _config_path = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
let _deepseek_key = EnvVarGuard::remove("DEEPSEEK_API_KEY");
let _openai_key = EnvVarGuard::remove("OPENAI_API_KEY");
let config = Config {
providers: Some(ProvidersConfig {
openai: ProviderConfig {
api_key: Some("openai-config-key".to_string()),
..ProviderConfig::default()
},
..ProvidersConfig::default()
}),
..Config::default()
};
let app = App::new(test_options(false), &config);
assert_eq!(app.api_provider, ApiProvider::Openai);
assert!(
!app.onboarding_needs_api_key,
"OpenAI provider config key should satisfy startup auth without a DeepSeek key"
);
assert!(!app.api_key_env_only);
}
#[test]
fn saved_startup_provider_overrides_config_file_provider() {
let _lock = lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let config_path = tmp.path().join("config.toml");
std::fs::write(
tmp.path().join("settings.toml"),
"default_provider = \"deepseek\"\ndefault_model = \"deepseek-v4-pro\"\n",
)
.expect("settings");
let _config_path = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
let config = Config {
provider: Some("xiaomi-mimo".to_string()),
providers: Some(ProvidersConfig {
deepseek: ProviderConfig {
api_key: Some("deepseek-config-key".to_string()),
model: Some("deepseek-v4-pro".to_string()),
..ProviderConfig::default()
},
xiaomi_mimo: ProviderConfig {
api_key: Some("mimo-config-key".to_string()),
model: Some("mimo-v2.5-pro".to_string()),
..ProviderConfig::default()
},
..ProvidersConfig::default()
}),
..Config::default()
};
let mut options = test_options(false);
options.model = "mimo-v2.5-pro".to_string();
let app = App::new(options, &config);
assert_eq!(app.api_provider, ApiProvider::Deepseek);
assert_eq!(app.model, "deepseek-v4-pro");
assert!(
!app.onboarding_needs_api_key,
"the saved startup provider's config key should satisfy startup auth"
);
}
#[test]
fn explicit_launch_provider_overrides_saved_startup_provider() {
let _lock = lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let config_path = tmp.path().join("config.toml");
std::fs::write(
tmp.path().join("settings.toml"),
"default_provider = \"deepseek\"\ndefault_model = \"deepseek-v4-pro\"\n",
)
.expect("settings");
let _config_path = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
let _provider = EnvVarGuard::set("CODEWHALE_PROVIDER", "xiaomi-mimo");
let config = Config {
provider: Some("xiaomi-mimo".to_string()),
providers: Some(ProvidersConfig {
deepseek: ProviderConfig {
api_key: Some("deepseek-config-key".to_string()),
model: Some("deepseek-v4-pro".to_string()),
..ProviderConfig::default()
},
xiaomi_mimo: ProviderConfig {
api_key: Some("mimo-config-key".to_string()),
model: Some("mimo-v2.5-pro".to_string()),
..ProviderConfig::default()
},
..ProvidersConfig::default()
}),
..Config::default()
};
let mut options = test_options(false);
options.model = "mimo-v2.5-pro".to_string();
let app = App::new(options, &config);
assert_eq!(app.api_provider, ApiProvider::XiaomiMimo);
assert_eq!(app.model, "mimo-v2.5-pro");
}
#[test]
fn app_new_defaults_auto_compact_on_for_256k_class_models_when_unset() {
let _lock = lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let config_path = tmp.path().join("config.toml");
let _config_path = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
let mut options = test_options(false);
options.model = "trinity-large-thinking".to_string();
let app = App::new(options, &Config::default());
assert!(app.auto_compact);
assert!(!app.auto_compact_user_configured);
assert_eq!(app.auto_compact_threshold_percent, 80.0);
assert_eq!(app.compact_threshold, 195_584);
}
#[test]
fn app_new_defaults_auto_compact_on_for_v4_class_models_when_unset() {
let _lock = lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let config_path = tmp.path().join("config.toml");
let _config_path = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
let mut options = test_options(false);
options.model = "deepseek-v4-pro".to_string();
let app = App::new(options, &Config::default());
assert!(app.auto_compact);
assert!(!app.auto_compact_user_configured);
assert_eq!(app.auto_compact_threshold_percent, 80.0);
assert_eq!(app.compact_threshold, 736_832);
}
#[test]
fn app_new_respects_explicit_auto_compact_false_for_256k_class_models() {
let _lock = lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let config_path = tmp.path().join("config.toml");
std::fs::write(tmp.path().join("settings.toml"), "auto_compact = false\n").expect("settings");
let _config_path = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
let mut options = test_options(false);
options.model = "trinity-large-thinking".to_string();
let app = App::new(options, &Config::default());
assert!(!app.auto_compact);
assert!(app.auto_compact_user_configured);
assert_eq!(app.compact_threshold, 195_584);
}
#[test]
fn app_new_respects_explicit_auto_compact_false_for_v4_class_models() {
let _lock = lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let config_path = tmp.path().join("config.toml");
std::fs::write(tmp.path().join("settings.toml"), "auto_compact = false\n").expect("settings");
let _config_path = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
let mut options = test_options(false);
options.model = "deepseek-v4-pro".to_string();
let app = App::new(options, &Config::default());
assert!(!app.auto_compact);
assert!(app.auto_compact_user_configured);
assert_eq!(app.compact_threshold, 736_832);
}
#[test]
fn cny_display_falls_back_to_usd_for_usd_only_costs() {
let mut app = App::new(test_options(false), &Config::default());
app.cost_currency = CostCurrency::Cny;
app.accrue_session_cost_estimate(CostEstimate::usd_only(0.42));
app.session.cost_priced_turns = 1;
let displayed = app.displayed_session_cost_for_currency(CostCurrency::Cny);
assert_eq!(displayed, 0.42);
assert_eq!(app.session_cost_for_currency(CostCurrency::Cny), 0.42);
assert_eq!(app.format_cost_amount(displayed), "$0.42");
}
#[test]
fn cny_display_keeps_cny_when_costs_have_cny_rates() {
let mut app = App::new(test_options(false), &Config::default());
app.cost_currency = CostCurrency::Cny;
app.accrue_session_cost_estimate(CostEstimate {
usd: 0.42,
cny: 2.5,
});
app.session.cost_priced_turns = 1;
app.session.cost_cny_priced_turns = 1;
let displayed = app.displayed_session_cost_for_currency(CostCurrency::Cny);
assert_eq!(displayed, 2.5);
assert_eq!(app.format_cost_amount(displayed), "¥2.50");
}
#[test]
fn cny_display_does_not_fall_back_to_an_unproven_usd_total() {
let mut app = App::new(test_options(false), &Config::default());
app.cost_currency = CostCurrency::Cny;
app.accrue_session_cost_estimate(CostEstimate::usd_only(0.42));
assert_eq!(
app.cost_display_currency(CostCurrency::Cny),
CostCurrency::Cny
);
assert_eq!(
app.displayed_session_cost_for_currency(CostCurrency::Cny),
0.0
);
}
#[test]
fn subscription_route_hides_stale_session_dollars_in_footer() {
let mut app = App::new(test_options(false), &Config::default());
app.accrue_session_cost_estimate(CostEstimate::usd_only(12.34));
app.billing_presentation =
crate::route_billing::BillingPresentation::Subscription("Codex OAuth quota");
let chip = app.cumulative_usage_chip();
assert!(
!matches!(chip, crate::route_billing::UsageChip::Money(_)),
"{chip:?}"
);
let rendered = crate::route_billing::format_usage_chip(&chip).unwrap_or_default();
assert!(!rendered.contains('$'), "{rendered}");
assert!(rendered.contains("Codex OAuth quota"), "{rendered}");
}
#[test]
fn provider_switch_keeps_audited_cumulative_spend_visible() {
let mut app = App::new(test_options(false), &Config::default());
let usage = crate::models::Usage {
input_tokens: 10_000,
output_tokens: 1_000,
..Default::default()
};
let priced = crate::pricing::audit_turn_cost_for_provider_at(
ApiProvider::Deepseek,
"deepseek-v4-flash",
&usage,
chrono::Utc::now(),
);
app.record_turn_cost_audit(&priced);
app.accrue_session_cost_estimate(priced.estimate.expect("priced"));
app.api_provider = ApiProvider::OpenaiCodex;
app.model = "gpt-5.5".to_string();
app.billing_presentation =
crate::route_billing::BillingPresentation::Subscription("Codex OAuth quota");
assert!(matches!(
app.cumulative_usage_chip(),
crate::route_billing::UsageChip::Money(_)
));
assert!(
crate::route_billing::format_usage_chip(&app.cumulative_usage_chip())
.is_some_and(|label| !label.is_empty())
);
let unknown = crate::pricing::audit_turn_cost_for_route_at(
ApiProvider::Openai,
"gpt-5.5",
Some(crate::pricing::UNCLASSIFIED_BILLING_SURFACE),
&usage,
chrono::Utc::now(),
);
app.record_turn_cost_audit(&unknown);
assert!(matches!(
app.cumulative_usage_chip(),
crate::route_billing::UsageChip::PricedSubtotal { legacy: false, .. }
));
}
#[test]
fn slash_command_classifier_treats_absolute_path_as_message() {
assert!(looks_like_slash_command_input("/"));
assert!(looks_like_slash_command_input("/help"));
assert!(looks_like_slash_command_input("/model deepseek-v4-pro"));
assert!(!looks_like_slash_command_input("/ hello"));
assert!(!looks_like_slash_command_input(" / hello"));
assert!(!looks_like_slash_command_input(
"/usr/lib/x86_64-linux-gnu/ 是标准路径吗?"
));
}
#[test]
fn bang_shell_prefix_parses_compact_and_spaced_forms() {
assert_eq!(shell_command_from_bang_input("!pwd"), Ok(Some("pwd")));
assert_eq!(shell_command_from_bang_input("! pwd"), Ok(Some("pwd")));
assert_eq!(
shell_command_from_bang_input(" ! cargo test -p codewhale-tui sidebar"),
Ok(Some("cargo test -p codewhale-tui sidebar"))
);
assert_eq!(shell_command_from_bang_input("normal message"), Ok(None));
}
#[test]
fn bang_shell_prefix_rejects_empty_command() {
assert_eq!(
shell_command_from_bang_input("!"),
Err("Usage: ! <shell command>")
);
assert_eq!(
shell_command_from_bang_input("! "),
Err("Usage: ! <shell command>")
);
}
#[test]
fn stop_word_matching_requires_one_token() {
let words = vec!["stop".to_string(), "wait".to_string(), "pause".to_string()];
assert_eq!(is_stop_word("STOP", &words).as_deref(), Some("stop"));
assert_eq!(is_stop_word("+ stop", &words).as_deref(), Some("stop"));
assert_eq!(is_stop_word("!wait", &words).as_deref(), Some("wait"));
assert_eq!(is_stop_word("pause.", &words).as_deref(), Some("pause"));
assert!(is_stop_word("please stop", &words).is_none());
assert!(is_stop_word("don't stop", &words).is_none());
}
#[test]
fn submit_input_records_absolute_slash_path_as_message_history() {
let mut app = App::new(test_options(false), &Config::default());
let input = "/usr/lib/x86_64-linux-gnu/ 是标准路径吗?";
app.input = input.to_string();
app.cursor_position = input.chars().count();
let submitted = app.submit_input().expect("expected submitted input");
assert_eq!(submitted, input);
assert_eq!(app.input_history.last().map(String::as_str), Some(input));
}
#[test]
fn restore_last_submitted_prompt_rehydrates_empty_composer() {
let mut app = App::new(test_options(false), &Config::default());
app.last_submitted_prompt = Some("fix the typo\nand retry".to_string());
assert!(app.restore_last_submitted_prompt_if_empty());
assert_eq!(app.input, "fix the typo\nand retry");
assert_eq!(app.cursor_position, app.input.chars().count());
assert!(app.needs_redraw);
}
#[test]
fn restore_last_submitted_prompt_preserves_existing_draft() {
let mut app = App::new(test_options(false), &Config::default());
app.last_submitted_prompt = Some("previous prompt".to_string());
app.input = "new draft".to_string();
app.cursor_position = app.input.chars().count();
assert!(!app.restore_last_submitted_prompt_if_empty());
assert_eq!(app.input, "new draft");
assert_eq!(app.cursor_position, "new draft".chars().count());
}
#[test]
fn composer_strips_raw_sgr_mouse_report_when_mouse_capture_is_enabled() {
let mut app = App::new(test_options(false), &Config::default());
app.use_mouse_capture = true;
app.insert_str("[<35;44;18M");
assert_eq!(app.input, "");
assert_eq!(app.cursor_position, 0);
}
#[test]
fn composer_strips_corrupted_mouse_report_burst() {
let mut app = App::new(test_options(false), &Config::default());
app.use_mouse_capture = true;
app.insert_str("draft ");
let leaked = "43;19M[<35;44;18M[<35;45;18M5;46;18M;48;18M";
app.insert_str(leaked);
assert_eq!(app.input, "draft ");
assert_eq!(app.cursor_position, "draft ".chars().count());
}
#[test]
fn composer_preserves_draft_suffix_when_stripping_mouse_report() {
let mut app = App::new(test_options(false), &Config::default());
app.use_mouse_capture = true;
app.insert_str("commit -m");
app.insert_str("[<65;44;18M");
assert_eq!(app.input, "commit -m");
assert_eq!(app.cursor_position, "commit -m".chars().count());
}
#[test]
fn composer_preserves_numeric_draft_when_stripping_mouse_report() {
let mut app = App::new(test_options(false), &Config::default());
app.use_mouse_capture = true;
app.insert_str("123");
app.insert_str("[<65;44;18M");
assert_eq!(app.input, "123");
assert_eq!(app.cursor_position, 3);
}
#[test]
fn composer_strips_raw_sgr_mouse_report_when_mouse_capture_is_disabled() {
let mut app = App::new(test_options(false), &Config::default());
app.insert_str("[<35;44;18M");
assert_eq!(app.input, "");
assert_eq!(app.cursor_position, 0);
}
#[test]
fn composer_strips_tail_only_mouse_report_burst_when_mouse_capture_is_disabled() {
let mut app = App::new(test_options(false), &Config::default());
app.insert_str("draft ");
app.insert_str(";76;20M35;74;22M35;73;23M");
assert_eq!(app.input, "draft ");
assert_eq!(app.cursor_position, "draft ".chars().count());
}
#[test]
fn composer_keeps_coordinate_like_text_when_mouse_capture_is_disabled() {
let mut app = App::new(test_options(false), &Config::default());
app.insert_str("Size 12;34M");
assert_eq!(app.input, "Size 12;34M");
assert_eq!(app.cursor_position, "Size 12;34M".chars().count());
}
#[test]
fn composer_keeps_normal_bracket_text_with_mouse_capture_enabled() {
let mut app = App::new(test_options(false), &Config::default());
app.use_mouse_capture = true;
app.insert_str("Use [<tag>] normally");
assert_eq!(app.input, "Use [<tag>] normally");
}
#[test]
fn composer_keeps_coordinate_like_text_with_mouse_capture_enabled() {
let mut app = App::new(test_options(false), &Config::default());
app.use_mouse_capture = true;
app.insert_str("Size 12;34M");
assert_eq!(app.input, "Size 12;34M");
}
#[test]
fn composer_strips_osc8_hyperlink_fragment() {
let mut app = App::new(test_options(false), &Config::default());
app.use_mouse_capture = true;
app.insert_str("draft ");
app.insert_str("]8;;https://example.com");
assert_eq!(app.input, "draft ");
assert_eq!(app.cursor_position, "draft ".chars().count());
}
#[test]
fn composer_strips_closing_osc8_fragment() {
let mut app = App::new(test_options(false), &Config::default());
app.use_mouse_capture = true;
app.insert_str("hello ");
app.insert_str("]8;;\\");
assert_eq!(app.input, "hello ");
assert_eq!(app.cursor_position, "hello ".chars().count());
}
#[test]
fn composer_strips_kitty_keyboard_protocol_fragment() {
let mut app = App::new(test_options(false), &Config::default());
app.use_mouse_capture = true;
app.insert_str("ready ");
app.insert_str("[?1u[>1u[<1u[?u");
assert_eq!(app.input, "ready ");
assert_eq!(app.cursor_position, "ready ".chars().count());
}
#[test]
fn composer_strips_dec_private_mode_set_reset_fragments() {
let mut app = App::new(test_options(false), &Config::default());
app.use_mouse_capture = true;
app.insert_str("ok ");
app.insert_str("[?2004h[?2004l[?1000h[?1004h[?2026h[?25l");
assert_eq!(app.input, "ok ");
assert_eq!(app.cursor_position, "ok ".chars().count());
}
#[test]
fn composer_keeps_bracket_question_word_text() {
let mut app = App::new(test_options(false), &Config::default());
app.use_mouse_capture = true;
app.insert_str("[?help] and [?later]");
assert_eq!(app.input, "[?help] and [?later]");
}
#[test]
fn composer_strips_mixed_control_sequence_burst() {
let mut app = App::new(test_options(false), &Config::default());
app.use_mouse_capture = true;
app.insert_str("hi");
app.insert_str("[<35;44;18M]8;;https://example.com[?1u");
assert_eq!(app.input, "hi");
assert_eq!(app.cursor_position, 2);
}
#[test]
fn composer_keeps_legitimate_url_text_with_mouse_capture_enabled() {
let mut app = App::new(test_options(false), &Config::default());
app.use_mouse_capture = true;
app.insert_str("see https://example.com/path?a=1&b=2 for info");
assert_eq!(app.input, "see https://example.com/path?a=1&b=2 for info");
}
#[test]
fn composer_keeps_legitimate_bracket_question_text() {
let mut app = App::new(test_options(false), &Config::default());
app.use_mouse_capture = true;
app.insert_str("[is this ok?] sure");
assert_eq!(app.input, "[is this ok?] sure");
}
#[test]
fn composer_keeps_legitimate_closing_bracket_digit_text() {
let mut app = App::new(test_options(false), &Config::default());
app.use_mouse_capture = true;
app.insert_str("array[]8 elements");
assert_eq!(app.input, "array[]8 elements");
}
#[test]
fn skip_onboarding_suppresses_all_onboarding_states() {
assert_eq!(
initial_onboarding_state(true, false, true, true),
OnboardingState::None
);
assert_eq!(
initial_onboarding_state(true, true, true, true),
OnboardingState::None
);
}
#[test]
fn fully_configured_returning_user_skips_onboarding() {
assert_eq!(
initial_onboarding_state(false, true, false, false),
OnboardingState::None
);
}
#[test]
fn returning_user_missing_api_key_goes_to_canonical_provider_setup() {
assert_eq!(
initial_onboarding_state(false, true, true, false),
OnboardingState::Provider
);
assert_eq!(
initial_onboarding_state(false, true, true, true),
OnboardingState::Provider
);
}
#[test]
fn first_run_user_always_starts_at_welcome() {
assert_eq!(
initial_onboarding_state(false, false, false, false),
OnboardingState::Welcome
);
assert_eq!(
initial_onboarding_state(false, false, true, false),
OnboardingState::Welcome
);
assert_eq!(
initial_onboarding_state(false, false, false, true),
OnboardingState::Welcome
);
}
#[test]
fn onboarding_workspace_trust_gate_only_fires_for_onboarded_user() {
assert!(onboarding_is_workspace_trust_gate(false, true, false, true));
assert!(!onboarding_is_workspace_trust_gate(true, true, false, true));
assert!(!onboarding_is_workspace_trust_gate(false, true, true, true));
assert!(!onboarding_is_workspace_trust_gate(
false, false, false, true
));
}
#[test]
fn onboarded_user_still_gets_workspace_trust_prompt_when_needed() {
assert_eq!(
initial_onboarding_state(false, true, false, true),
OnboardingState::TrustDirectory
);
}
#[test]
fn app_new_detects_missing_api_key_with_default_config() {
let _lock = lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let config_path = tmp.path().join("config.toml");
let _config_path = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
let _provider_env = EnvVarGuard::remove("CODEWHALE_PROVIDER");
let _legacy_provider_env = EnvVarGuard::remove("DEEPSEEK_PROVIDER");
let _api_key_envs: Vec<_> = [
"DEEPSEEK_API_KEY",
"NVIDIA_API_KEY",
"NVIDIA_NIM_API_KEY",
"OPENAI_API_KEY",
"ATLASCLOUD_API_KEY",
"WANJIE_ARK_API_KEY",
"WANJIE_API_KEY",
"WANJIE_MAAS_API_KEY",
"OPENROUTER_API_KEY",
"NOVITA_API_KEY",
"FIREWORKS_API_KEY",
"SILICONFLOW_API_KEY",
"MOONSHOT_API_KEY",
"KIMI_API_KEY",
"SGLANG_API_KEY",
"VLLM_API_KEY",
"OLLAMA_API_KEY",
]
.into_iter()
.map(EnvVarGuard::remove)
.collect();
let app = App::new(test_options(false), &Config::default());
assert!(
app.onboarding_needs_api_key,
"default config (no key) must set onboarding_needs_api_key"
);
}
#[test]
fn app_new_with_explicit_api_key_does_not_trigger_onboarding() {
let _lock = lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let config_path = tmp.path().join("config.toml");
let _config_path = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
let _provider_env = EnvVarGuard::remove("CODEWHALE_PROVIDER");
let _legacy_provider_env = EnvVarGuard::remove("DEEPSEEK_PROVIDER");
let config = Config {
api_key: Some("sk-test-onboarding-key".to_string()),
..Config::default()
};
let app = App::new(test_options(false), &config);
assert!(
!app.onboarding_needs_api_key,
"explicit config.api_key must satisfy the onboarding check"
);
}
#[test]
fn new_caches_workspace_skills_for_slash_menu() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let workspace = tmp.path().join("workspace");
let skill_dir = workspace.join(".agents").join("skills").join("local-skill");
std::fs::create_dir_all(&skill_dir).expect("skill dir");
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: local-skill\ndescription: Local workspace skill\n---\nUse the local skill.\n",
)
.expect("skill file");
let mut options = test_options(false);
options.workspace = workspace.clone();
options.skills_dir = tmp.path().join("global-skills");
let app = App::new(options, &Config::default());
assert_eq!(app.skills_dir, workspace.join(".agents").join("skills"));
assert!(app.cached_skills.iter().any(|(name, description)| {
name == "local-skill" && description == "Local workspace skill"
}));
}
#[test]
fn cached_skills_merges_across_candidate_directories() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let workspace = tmp.path().join("workspace");
std::fs::create_dir_all(workspace.join(".agents").join("skills").join("foo"))
.expect("stale empty dir");
let real_dir = workspace.join(".claude").join("skills").join("foo");
std::fs::create_dir_all(&real_dir).expect("real skill dir");
std::fs::write(
real_dir.join("SKILL.md"),
"---\nname: foo\ndescription: Real foo skill\n---\nbody\n",
)
.expect("skill file");
let mut options = test_options(false);
options.workspace = workspace.clone();
options.skills_dir = tmp.path().join("global-skills");
let app = App::new(options, &Config::default());
assert!(
app.cached_skills
.iter()
.any(|(name, description)| name == "foo" && description == "Real foo skill"),
"cached_skills should fall through to lower-precedence dir when higher-precedence one has an empty stub: {:?}",
app.cached_skills,
);
}
#[test]
fn cached_skills_respect_codewhale_only_scan_config() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let workspace = tmp.path().join("workspace");
let claude_dir = workspace
.join(".claude")
.join("skills")
.join("claude-skill");
std::fs::create_dir_all(&claude_dir).expect("claude skill dir");
std::fs::write(
claude_dir.join("SKILL.md"),
"---\nname: claude-skill\ndescription: Claude skill\n---\nbody\n",
)
.expect("write claude skill");
let codewhale_dir = workspace
.join(".codewhale")
.join("skills")
.join("codewhale-skill");
std::fs::create_dir_all(&codewhale_dir).expect("codewhale skill dir");
std::fs::write(
codewhale_dir.join("SKILL.md"),
"---\nname: codewhale-skill\ndescription: CodeWhale skill\n---\nbody\n",
)
.expect("write codewhale skill");
let mut options = test_options(false);
options.workspace = workspace.clone();
options.skills_dir = tmp.path().join("global-skills");
let app = App::new(
options,
&Config {
skills: Some(crate::config::SkillsConfig {
scan_codewhale_only: Some(true),
..Default::default()
}),
..Default::default()
},
);
assert_eq!(app.skills_dir, workspace.join(".codewhale").join("skills"));
assert!(
app.cached_skills
.iter()
.any(|(name, _)| name == "codewhale-skill"),
"CodeWhale skill should be cached: {:?}",
app.cached_skills
);
assert!(
!app.cached_skills
.iter()
.any(|(name, _)| name == "claude-skill"),
"strict scan should not cache Claude skills: {:?}",
app.cached_skills
);
}
#[test]
fn resolve_skills_dir_requires_codewhale_skills_to_be_directory() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let workspace = tmp.path().join("workspace");
std::fs::create_dir_all(workspace.join(".codewhale")).expect("codewhale dir");
std::fs::write(
workspace.join(".codewhale").join("skills"),
"not a directory",
)
.expect("skills file");
let global_skills_dir = tmp.path().join("global-skills");
let config = Config {
skills: Some(crate::config::SkillsConfig {
scan_codewhale_only: Some(true),
..Default::default()
}),
..Default::default()
};
let resolved = resolve_skills_dir(&workspace, &global_skills_dir, &config);
assert_eq!(resolved, global_skills_dir);
}
#[test]
fn cached_skills_include_configured_directory() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let workspace = tmp.path().join("workspace");
let configured_dir = tmp.path().join("configured-skills");
let configured_skill_dir = configured_dir.join("configured-skill");
std::fs::create_dir_all(&configured_skill_dir).expect("configured skill dir");
std::fs::write(
configured_skill_dir.join("SKILL.md"),
"---\nname: configured-skill\ndescription: Configured skill\n---\nbody\n",
)
.expect("write configured skill");
let mut options = test_options(false);
options.workspace = workspace.clone();
options.skills_dir = configured_dir.clone();
let config = Config {
skills_dir: Some(configured_dir.to_string_lossy().into_owned()),
..Default::default()
};
let app = App::new(options, &config);
assert!(
app.cached_skills
.iter()
.any(|(name, description)| name == "configured-skill"
&& description == "Configured skill"),
"configured skill dir should be merged: {:?}",
app.cached_skills
);
}
#[test]
fn cached_skills_preserve_configured_directory_in_codewhale_only_scan() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let workspace = tmp.path().join("workspace");
let codewhale_skill_dir = workspace
.join(".codewhale")
.join("skills")
.join("workspace-codewhale");
std::fs::create_dir_all(&codewhale_skill_dir).expect("workspace codewhale skill dir");
std::fs::write(
codewhale_skill_dir.join("SKILL.md"),
"---\nname: workspace-codewhale\ndescription: Workspace CodeWhale skill\n---\nbody\n",
)
.expect("write workspace codewhale skill");
let configured_dir = tmp.path().join("configured-skills");
let configured_skill_dir = configured_dir.join("configured-skill");
std::fs::create_dir_all(&configured_skill_dir).expect("configured skill dir");
std::fs::write(
configured_skill_dir.join("SKILL.md"),
"---\nname: configured-skill\ndescription: Configured skill\n---\nbody\n",
)
.expect("write configured skill");
let mut options = test_options(false);
options.workspace = workspace.clone();
options.skills_dir = configured_dir.clone();
let config = Config {
skills_dir: Some(configured_dir.to_string_lossy().into_owned()),
skills: Some(crate::config::SkillsConfig {
scan_codewhale_only: Some(true),
..Default::default()
}),
..Default::default()
};
let app = App::new(options, &config);
assert_eq!(app.skills_dir, configured_dir);
assert!(
app.cached_skills
.iter()
.any(|(name, _)| name == "workspace-codewhale"),
"workspace CodeWhale skill should still be cached: {:?}",
app.cached_skills
);
assert!(
app.cached_skills
.iter()
.any(|(name, _)| name == "configured-skill"),
"explicit configured skills_dir should still be cached: {:?}",
app.cached_skills
);
}
#[test]
fn cached_skills_reject_codewhale_only_workspace_symlink_escape() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let workspace = tmp.path().join("workspace");
let escape_target = tmp.path().join("escape-target");
let escaped_skill_dir = escape_target.join("escaped-skill");
std::fs::create_dir_all(workspace.join(".codewhale")).expect("codewhale dir");
std::fs::create_dir_all(&escaped_skill_dir).expect("escaped skill dir");
std::fs::write(
escaped_skill_dir.join("SKILL.md"),
"---\nname: escaped-skill\ndescription: Escaped skill\n---\nbody\n",
)
.expect("write escaped skill");
let link_path = workspace.join(".codewhale").join("skills");
if create_dir_symlink(&escape_target, &link_path).is_err() {
return;
}
let global_skills_dir = tmp.path().join("global-skills");
let mut options = test_options(false);
options.workspace = workspace.clone();
options.skills_dir = global_skills_dir.clone();
let config = Config {
skills: Some(crate::config::SkillsConfig {
scan_codewhale_only: Some(true),
..Default::default()
}),
..Default::default()
};
let app = App::new(options, &config);
assert_eq!(app.skills_dir, global_skills_dir);
assert!(
!app.cached_skills
.iter()
.any(|(name, _)| name == "escaped-skill"),
"strict app cache must not follow escaped workspace CodeWhale symlinks: {:?}",
app.cached_skills
);
}
#[test]
fn paste_defers_oversized_text_consolidation_until_submit() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let mut opts = test_options(false);
opts.workspace = tmp.path().to_path_buf();
let mut app = App::new(opts, &Config::default());
let full_content = "y".repeat(MAX_SUBMITTED_INPUT_CHARS + 256);
app.insert_paste_text(&full_content);
assert_eq!(app.input, full_content);
assert_eq!(app.cursor_position, app.input.chars().count());
let pastes_dir = tmp.path().join(".codewhale/pastes");
assert!(
!pastes_dir.exists() || std::fs::read_dir(&pastes_dir).unwrap().next().is_none(),
"paste file should not be written before submit"
);
assert!(
app.status_toasts
.iter()
.all(|toast| !toast.text.contains("backed up")),
"backup toast should not appear before submit"
);
let submitted = app.submit_input().expect("expected submitted input");
assert!(
submitted.starts_with("@.codewhale/pastes/paste-"),
"submitted should be the @mention only, got: {}",
&submitted[..submitted.len().min(80)]
);
assert!(submitted.ends_with(".md"), "expected .md extension");
let mention = &submitted[1..]; let abs = tmp.path().join(mention);
assert!(abs.is_file(), "paste file must exist at {abs:?}");
let written = std::fs::read_to_string(&abs).expect("read");
assert_eq!(written, full_content);
assert!(
app.status_toasts
.iter()
.any(|toast| toast.text.contains("backed up")),
"expected backup toast after submit"
);
}
#[test]
fn paste_under_threshold_does_not_consolidate() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let mut opts = test_options(false);
opts.workspace = tmp.path().to_path_buf();
let mut app = App::new(opts, &Config::default());
let small = "hello world\nthis is fine".to_string();
app.insert_paste_text(&small);
assert_eq!(app.input, small);
assert!(!app.input.starts_with("@.codewhale/pastes/"));
let pastes_dir = tmp.path().join(".codewhale/pastes");
assert!(
!pastes_dir.exists() || std::fs::read_dir(&pastes_dir).unwrap().next().is_none(),
"no paste file should be written for under-cap content"
);
}
#[test]
fn large_multiline_paste_preserves_exact_bytes_through_submit() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let mut opts = test_options(false);
opts.workspace = tmp.path().to_path_buf();
let mut app = App::new(opts, &Config::default());
let payload = format!(
"Mission path: /Volumes/VIXinSSD/CW/worktrees/codewhale-v091-exact-88a158-ci\n\
SHA: 0dfe9170a10e081fe48b23239f22d33260f4fa24\n\
Branch: codex/v091-local-candidate-20260722\n\
Paths that must not truncate: codewhale-v091-exact-88a158-ci worktrees/codewhale-v091-exact-88a158-ci\n\
Mixed punctuation: a;b:c[m]<n> digits 0123456789 and hyphens-ok\n\
Unicode: 你好世界 café — keep every codepoint.\n\
{}",
"line-body-".repeat(200)
);
assert!(
payload.chars().count() < MAX_SUBMITTED_INPUT_CHARS,
"fixture must stay under submit consolidation threshold"
);
app.insert_paste_text(&payload);
assert_eq!(
app.input, payload,
"composer input must equal pasted payload exactly"
);
let submitted = app.submit_input().expect("submit");
assert_eq!(
submitted, payload,
"submitted bytes must equal pasted payload exactly"
);
}
#[test]
fn submit_input_consolidates_oversized_input_into_paste_file() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let mut opts = test_options(false);
opts.workspace = tmp.path().to_path_buf();
let mut app = App::new(opts, &Config::default());
let full_content = "x".repeat(MAX_SUBMITTED_INPUT_CHARS + 128);
app.input = full_content.clone();
app.cursor_position = app.input.chars().count();
let submitted = app.submit_input().expect("expected submitted input");
assert!(
submitted.starts_with("@.codewhale/pastes/paste-"),
"submitted text should be the @mention, got: {}",
&submitted[..submitted.len().min(80)]
);
assert!(
submitted.ends_with(".md"),
"expected .md extension, got: {submitted}"
);
let mention = &submitted[1..]; let abs_path = tmp.path().join(mention);
assert!(abs_path.is_file(), "paste file must exist at {abs_path:?}");
let written = std::fs::read_to_string(&abs_path).expect("read paste file");
assert_eq!(written, full_content);
assert!(
app.status_toasts
.iter()
.any(|toast| toast.text.contains("backed up")),
"expected backup toast, got: {:?}",
app.status_toasts
.iter()
.map(|t| &t.text)
.collect::<Vec<_>>()
);
assert!(app.input.is_empty());
}
#[test]
fn app_starts_without_seeded_transcript_messages() {
let app = App::new(test_options(false), &Config::default());
assert!(app.history.is_empty());
assert_eq!(app.history_version, 0);
}
#[test]
fn clear_todos_resets_todos_list() {
let mut app = App::new(test_options(false), &Config::default());
{
let mut todos = app.todos.try_lock().expect("todos lock");
todos.add("buy milk".to_string(), TodoStatus::Pending);
todos.add("write code".to_string(), TodoStatus::InProgress);
assert_eq!(todos.snapshot().items.len(), 2);
}
assert!(app.clear_todos());
let todos = app.todos.try_lock().expect("todos lock");
assert!(todos.snapshot().items.is_empty());
}
#[test]
fn clear_todos_resets_plan_state() {
let mut app = App::new(test_options(false), &Config::default());
{
let mut plan = app
.plan_state
.try_lock()
.expect("plan lock should be available");
plan.update(UpdatePlanArgs {
explanation: Some("test plan".to_string()),
plan: vec![PlanItemArg {
step: "step 1".to_string(),
status: StepStatus::InProgress,
}],
..UpdatePlanArgs::default()
});
assert!(!plan.snapshot().is_empty());
}
assert!(app.clear_todos());
let plan = app
.plan_state
.try_lock()
.expect("plan lock should be available");
assert!(plan.snapshot().is_empty());
}
#[test]
fn work_state_snapshot_round_trips_todos_and_plan() {
let app = App::new(test_options(false), &Config::default());
{
let mut todos = app.todos.try_lock().expect("todos lock");
todos.add("inspect".to_string(), TodoStatus::Completed);
todos.add("patch".to_string(), TodoStatus::InProgress);
}
{
let mut plan = app.plan_state.try_lock().expect("plan lock");
plan.update(UpdatePlanArgs {
objective: Some("Keep Work durable".to_string()),
plan: vec![PlanItemArg {
step: "verify".to_string(),
status: StepStatus::InProgress,
}],
..UpdatePlanArgs::default()
});
}
let state = app
.work_state_snapshot()
.expect("snapshot locks")
.expect("non-empty state");
let mut restored = App::new(test_options(false), &Config::default());
let restored_workspace = restored.workspace.clone();
restored
.restore_work_state("restored-session", &restored_workspace, Some(&state))
.expect("restore Work state");
assert_eq!(
restored.work_state_snapshot().expect("snapshot"),
Some(state)
);
}
#[test]
fn work_restore_reconciles_fleet_from_the_restored_workspace() {
let restored_workspace = tempfile::tempdir().expect("restored workspace");
let ledger = crate::fleet::ledger::FleetLedger::open(restored_workspace.path())
.expect("open restored Fleet ledger");
ledger
.enqueue(codewhale_protocol::fleet::FleetInboxEntry {
run_id: codewhale_protocol::fleet::FleetRunId::from("run-restore"),
task_id: "task-restore".to_string(),
priority: 0,
enqueued_at: "2026-07-18T00:00:00Z".to_string(),
lease_deadline: None,
attempts: 0,
})
.expect("enqueue restored Fleet task");
let source = crate::work_graph::new_shared_work_runtime(
crate::tools::todo::new_shared_todo_list(),
crate::tools::plan::new_shared_plan_state(),
);
source
.register_operation(
"restored-session",
crate::work_graph::OperationIntent::new(
"fleet:run-restore/task-restore",
"restored Fleet task",
true,
"fleet",
"restore-test",
),
)
.expect("register Fleet binding");
let captured = source
.capture(Some("restored-session"))
.expect("capture source Work state")
.expect("non-empty source Work state");
let state = crate::session_manager::SessionWorkState {
graph: Some(captured.graph),
todos: captured.todos,
plan: captured.plan,
};
let mut app = App::new(test_options(false), &Config::default());
assert_ne!(app.workspace, restored_workspace.path());
app.restore_work_state("restored-session", restored_workspace.path(), Some(&state))
.expect("restore Work state from target workspace");
let graph = app
.runtime_services
.work
.as_ref()
.expect("Work runtime")
.capture(Some("restored-session"))
.expect("capture restored Work state")
.expect("restored graph")
.graph;
let operation = graph
.nodes
.iter()
.find(|node| {
node.binding
.as_ref()
.is_some_and(|binding| binding.external == "fleet:run-restore/task-restore")
})
.expect("restored Fleet operation");
assert_eq!(
operation.state,
crate::work_graph::NodeState::Initializing,
"the target workspace ledger must outrank the app's previous workspace"
);
}
#[test]
fn failed_workspace_owner_reconcile_leaves_previous_work_state_intact() {
let restored_workspace = tempfile::tempdir().expect("restored workspace");
let ledger = crate::fleet::ledger::FleetLedger::open(restored_workspace.path())
.expect("open restored Fleet ledger");
ledger
.enqueue(codewhale_protocol::fleet::FleetInboxEntry {
run_id: codewhale_protocol::fleet::FleetRunId::from("run-regress"),
task_id: "task-regress".to_string(),
priority: 0,
enqueued_at: "2026-07-18T00:00:00Z".to_string(),
lease_deadline: None,
attempts: 0,
})
.expect("enqueue older Fleet owner state");
let incoming = crate::work_graph::new_shared_work_runtime(
crate::tools::todo::new_shared_todo_list(),
crate::tools::plan::new_shared_plan_state(),
);
incoming
.register_operation(
"incoming-session",
crate::work_graph::OperationIntent::new(
"fleet:run-regress/task-regress",
"newer saved Fleet task",
true,
"fleet",
"regression-test",
),
)
.expect("register incoming Fleet binding");
incoming
.reconcile_operation(
"incoming-session",
crate::work_graph::OperationOwnerSnapshot::new(
"fleet:run-regress/task-regress",
crate::work_graph::OwnerState::Running,
2,
2,
),
)
.expect("record newer saved owner sequence");
let incoming = incoming
.capture(Some("incoming-session"))
.expect("capture incoming state")
.expect("incoming graph");
let incoming = crate::session_manager::SessionWorkState {
graph: Some(incoming.graph),
todos: incoming.todos,
plan: incoming.plan,
};
let mut app = App::new(test_options(false), &Config::default());
let work = app
.runtime_services
.work
.as_ref()
.expect("Work runtime")
.clone();
work.register_operation(
"previous-session",
crate::work_graph::OperationIntent::new(
"shell:shell_previous",
"previous operation",
false,
"exec_shell",
"previous-test",
),
)
.expect("register previous state");
let before = work
.capture(Some("previous-session"))
.expect("capture previous state")
.expect("previous graph");
let error = app
.restore_work_state(
"incoming-session",
restored_workspace.path(),
Some(&incoming),
)
.expect_err("owner sequence regression must fail closed");
assert!(error.contains("sequence regressed"), "{error}");
assert_eq!(
work.capture(Some("previous-session"))
.expect("capture state after failed restore")
.expect("previous graph remains"),
before,
"failed restore must not replace any part of the previous Work state"
);
}
#[test]
fn clear_todos_is_atomic_and_invalidates_cached_work_summary() {
let mut app = App::new(test_options(false), &Config::default());
{
let mut todos = app.todos.try_lock().expect("todos lock");
todos.add("clear me".to_string(), TodoStatus::Pending);
}
app.cached_work_summary = Some(SidebarWorkSummary::default());
assert!(app.clear_todos());
assert!(app.cached_work_summary.is_none());
assert_eq!(app.work_state_snapshot().expect("snapshot"), None);
}
#[test]
fn entering_operate_preserves_user_rail_panel() {
let mut app = App::new(test_options(false), &Config::default());
app.work_surface.panel = crate::tui::work_surface::RailPanel::Agents;
assert!(app.set_mode(AppMode::Operate));
assert_eq!(
app.work_surface.panel,
crate::tui::work_surface::RailPanel::Agents
);
}
#[test]
fn app_mode_helpers_centralize_parse_labels_and_cycle_order() {
assert_eq!(AppMode::parse("agent"), Some(AppMode::Agent));
assert_eq!(AppMode::parse("act"), Some(AppMode::Agent));
assert_eq!(AppMode::parse("work"), Some(AppMode::Agent));
assert_eq!(AppMode::parse("2"), Some(AppMode::Plan));
assert_eq!(AppMode::parse("auto"), Some(AppMode::Agent));
assert_eq!(AppMode::parse("3"), Some(AppMode::Operate));
assert_eq!(AppMode::parse("operate"), Some(AppMode::Operate));
assert_eq!(AppMode::parse("YOLO"), Some(AppMode::Yolo));
assert_eq!(AppMode::parse("4"), Some(AppMode::Yolo));
assert_eq!(AppMode::parse("multitask"), None);
assert_eq!(AppMode::parse("5"), None);
assert_eq!(AppMode::parse("fast"), None);
assert_eq!(AppMode::from_setting("multitask"), AppMode::Operate);
assert_eq!(AppMode::from_setting("5"), AppMode::Operate);
assert_eq!(AppMode::Agent.as_setting(), "agent");
assert_eq!(AppMode::Auto.as_setting(), "agent");
assert_eq!(AppMode::Yolo.as_setting(), "agent");
assert_eq!(AppMode::Plan.display_name(), "Plan");
assert_eq!(AppMode::Auto.display_name(), "Act");
assert_eq!(AppMode::Auto.label(), "ACT");
assert_eq!(AppMode::Yolo.label(), "ACT");
assert_eq!(AppMode::Yolo.display_name(), "Act");
assert_eq!(AppMode::Agent.number(), '1');
assert_eq!(AppMode::Auto.number(), '1');
assert_eq!(AppMode::Yolo.number(), '1');
assert_eq!(AppMode::Operate.number(), '3');
assert_eq!(
AppMode::CYCLE,
[AppMode::Plan, AppMode::Agent, AppMode::Operate]
);
assert_eq!(AppMode::Plan.next(), AppMode::Agent);
assert_eq!(AppMode::Agent.next(), AppMode::Operate);
assert_eq!(AppMode::Operate.next(), AppMode::Plan);
assert_eq!(AppMode::Auto.next(), AppMode::Agent);
assert_eq!(AppMode::Yolo.next(), AppMode::Agent);
assert_eq!(AppMode::Plan.previous(), AppMode::Operate);
assert_eq!(AppMode::Agent.previous(), AppMode::Plan);
assert_eq!(AppMode::Operate.previous(), AppMode::Agent);
assert_eq!(AppMode::Auto.previous(), AppMode::Agent);
assert_eq!(AppMode::Yolo.previous(), AppMode::Agent);
}
#[test]
fn test_cycle_mode_transitions() {
let mut app = App::new(test_options(false), &Config::default());
let initial_mode = app.mode;
app.cycle_mode();
assert_ne!(app.mode, initial_mode);
}
#[test]
fn effective_route_display_tracks_inflight_and_last_auto_provider() {
let mut app = App::new(test_options(false), &Config::default());
app.auto_model = true;
app.pending_turn_route = Some((ApiProvider::Zai, "glm-5.2".to_string(), true));
assert_eq!(
app.effective_route_display(),
(ApiProvider::Zai, "glm-5.2".to_string())
);
app.pending_turn_route = None;
app.last_effective_provider = Some(ApiProvider::Xai);
app.last_effective_model = Some("grok-4.5".to_string());
assert_eq!(
app.effective_route_display(),
(ApiProvider::Xai, "grok-4.5".to_string())
);
}
#[test]
fn test_cycle_mode_reverse_transitions() {
let mut app = App::new(test_options(false), &Config::default());
app.mode = AppMode::Plan;
app.cycle_mode_reverse();
assert_eq!(app.mode, AppMode::Operate);
app.mode = AppMode::Operate;
app.cycle_mode_reverse();
assert_eq!(app.mode, AppMode::Agent);
app.mode = AppMode::Agent;
app.cycle_mode_reverse();
assert_eq!(app.mode, AppMode::Plan);
app.mode = AppMode::Auto;
app.cycle_mode_reverse();
assert_eq!(app.mode, AppMode::Agent);
}
#[test]
fn test_mode_switch_does_not_emit_redundant_toast() {
let mut app = App::new(test_options(false), &Config::default());
let first_mode = app.mode.next();
let second_mode = first_mode.next();
app.set_mode(first_mode);
app.sync_status_message_to_toasts();
assert!(app.status_toasts.is_empty());
app.set_mode(second_mode);
app.sync_status_message_to_toasts();
assert!(app.status_toasts.is_empty());
}
#[test]
fn test_mode_switch_toasts_do_not_disrupt_non_mode_toasts() {
let mut app = App::new(test_options(false), &Config::default());
app.yolo_compat_notified = true;
app.status_message = Some("Task queued".to_string());
app.sync_status_message_to_toasts();
app.set_mode(AppMode::Agent);
app.sync_status_message_to_toasts();
app.set_mode(AppMode::Yolo);
app.sync_status_message_to_toasts();
assert_eq!(app.status_toasts.len(), 1);
assert!(
app.status_toasts
.iter()
.any(|toast| toast.text == "Task queued")
);
}
#[test]
fn test_clear_input() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "test input".to_string();
app.cursor_position = app.input.len();
app.clear_input();
assert!(app.input.is_empty());
assert_eq!(app.cursor_position, 0);
}
#[test]
fn test_queue_message() {
let mut app = App::new(test_options(false), &Config::default());
app.queue_message(QueuedMessage::new("test message".to_string(), None));
assert_eq!(app.queued_message_count(), 1);
assert!(app.queued_messages.front().is_some());
}
#[test]
fn test_remove_queued_message() {
let mut app = App::new(test_options(false), &Config::default());
app.queue_message(QueuedMessage::new("first".to_string(), None));
app.queue_message(QueuedMessage::new("second".to_string(), None));
let removed = app.remove_queued_message(0);
assert!(removed.is_some());
assert_eq!(app.queued_message_count(), 1);
let removed = app.remove_queued_message(0);
assert!(removed.is_some());
assert_eq!(app.queued_message_count(), 0);
}
#[test]
fn test_remove_queued_message_invalid_index() {
let mut app = App::new(test_options(false), &Config::default());
app.queue_message(QueuedMessage::new("test".to_string(), None));
let removed = app.remove_queued_message(100);
assert!(removed.is_none());
}
#[test]
fn test_set_mode_updates_state() {
let mut app = App::new(test_options(false), &Config::default());
app.yolo_compat_notified = true;
app.set_mode(AppMode::Plan);
assert_eq!(app.mode, AppMode::Plan);
app.set_mode(AppMode::Yolo);
assert_eq!(app.mode, AppMode::Agent);
assert!(app.yolo);
assert!(app.trust_mode);
assert!(app.allow_shell);
assert_eq!(app.approval_mode, ApprovalMode::Bypass);
}
#[test]
fn app_new_respects_allow_shell_option_when_not_yolo() {
let mut options = test_options(false);
options.allow_shell = false;
options.start_in_agent_mode = true; let app = App::new(options, &Config::default());
assert!(!app.allow_shell);
}
#[test]
fn set_mode_yolo_restores_previous_policies_on_exit() {
let mut options = test_options(false);
options.allow_shell = false;
options.start_in_agent_mode = true; let mut app = App::new(options, &Config::default());
app.allow_shell = false;
app.trust_mode = false;
app.approval_mode = ApprovalMode::Never;
app.yolo_compat_notified = true;
app.set_mode(AppMode::Yolo);
assert!(app.allow_shell);
assert!(app.trust_mode);
assert_eq!(app.approval_mode, ApprovalMode::Bypass);
app.set_mode(AppMode::Agent);
assert!(!app.allow_shell);
assert!(!app.trust_mode);
assert_eq!(app.approval_mode, ApprovalMode::Never);
}
#[test]
fn set_mode_plan_restores_previous_approval_on_agent_exit() {
let config = Config {
approval_policy: Some("never".to_string()),
..Default::default()
};
let mut options = test_options(false);
options.start_in_agent_mode = true; let mut app = App::new(options, &config);
assert_eq!(app.mode, AppMode::Agent);
assert_eq!(app.approval_mode, ApprovalMode::Never);
app.set_mode(AppMode::Plan);
app.approval_mode = ApprovalMode::Suggest;
app.set_mode(AppMode::Agent);
assert_eq!(app.mode, AppMode::Agent);
assert_eq!(app.approval_mode, ApprovalMode::Never);
}
#[test]
fn set_mode_plan_to_yolo_keeps_yolo_permissions_and_restores_agent_baseline() {
let mut options = test_options(false);
options.allow_shell = false;
options.start_in_agent_mode = true; let mut app = App::new(options, &Config::default());
app.allow_shell = false;
app.trust_mode = false;
app.approval_mode = ApprovalMode::Never;
app.yolo_compat_notified = true;
app.set_mode(AppMode::Plan);
app.approval_mode = ApprovalMode::Suggest;
app.set_mode(AppMode::Yolo);
assert_eq!(app.mode, AppMode::Agent);
assert!(app.allow_shell);
assert!(app.trust_mode);
assert_eq!(app.approval_mode, ApprovalMode::Bypass);
app.set_mode(AppMode::Agent);
assert_eq!(app.mode, AppMode::Agent);
assert!(!app.allow_shell);
assert!(!app.trust_mode);
assert_eq!(app.approval_mode, ApprovalMode::Never);
}
#[test]
fn base_policy_for_mode_projects_the_mode_permission_table() {
let prefs = ModeSessionPrefs {
agent_allow_shell: true,
agent_trust_mode: true,
agent_approval_mode: ApprovalMode::Never,
};
let plan = base_policy_for_mode(AppMode::Plan, &prefs);
assert_eq!(plan.mode, AppMode::Plan);
assert!(!plan.allow_shell);
assert!(!plan.trust_mode);
assert_eq!(plan.approval_mode, ApprovalMode::Suggest);
let agent = base_policy_for_mode(AppMode::Agent, &prefs);
assert_eq!(agent.mode, AppMode::Agent);
assert!(agent.allow_shell);
assert!(agent.trust_mode);
assert_eq!(agent.approval_mode, ApprovalMode::Never);
let auto = base_policy_for_mode(AppMode::Auto, &prefs);
assert_eq!(auto.mode, AppMode::Auto);
assert!(auto.allow_shell);
assert!(auto.trust_mode);
assert_eq!(auto.approval_mode, ApprovalMode::Never);
let operate = base_policy_for_mode(AppMode::Operate, &prefs);
assert_eq!(operate.mode, AppMode::Operate);
assert_eq!(operate.allow_shell, agent.allow_shell);
assert_eq!(operate.trust_mode, agent.trust_mode);
assert_eq!(operate.approval_mode, ApprovalMode::Never);
let yolo = base_policy_for_mode(AppMode::Yolo, &prefs);
assert_eq!(yolo.mode, AppMode::Yolo);
assert!(yolo.allow_shell);
assert!(yolo.trust_mode);
assert_eq!(yolo.approval_mode, ApprovalMode::Bypass);
let minimal = ModeSessionPrefs {
agent_allow_shell: false,
agent_trust_mode: false,
agent_approval_mode: ApprovalMode::Suggest,
};
let agent_min = base_policy_for_mode(AppMode::Agent, &minimal);
assert!(!agent_min.allow_shell);
assert!(!agent_min.trust_mode);
assert_eq!(agent_min.approval_mode, ApprovalMode::Suggest);
let operate_min = base_policy_for_mode(AppMode::Operate, &minimal);
assert!(!operate_min.allow_shell);
assert!(!operate_min.trust_mode);
assert_eq!(operate_min.approval_mode, ApprovalMode::Suggest);
}
#[test]
fn cycle_approval_posture_cycles_suggest_auto_bypass() {
let _env_lock = lock_test_env();
let tmp = tempfile::tempdir().expect("tempdir");
let config_path = tmp.path().join("config.toml");
let _config_env = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
let mut options = test_options(false);
options.start_in_agent_mode = true;
options.config_path = Some(config_path);
let mut app = App::new(options, &Config::default());
app.approval_mode = ApprovalMode::Suggest;
assert!(app.cycle_approval_posture());
assert_eq!(app.approval_mode, ApprovalMode::Auto);
assert!(app.cycle_approval_posture());
assert_eq!(app.approval_mode, ApprovalMode::Bypass);
assert!(app.cycle_approval_posture());
assert_eq!(app.approval_mode, ApprovalMode::Suggest);
let persisted = std::fs::read_to_string(tmp.path().join("settings.toml")).expect("settings");
assert!(persisted.contains("permission_posture = \"ask\""));
}
#[test]
fn cycle_approval_posture_emits_rebinding_notice_once() {
let _env_lock = lock_test_env();
let tmp = tempfile::tempdir().expect("tempdir");
let config_path = tmp.path().join("config.toml");
let _config_env = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
let mut options = test_options(false);
options.start_in_agent_mode = true;
options.config_path = Some(config_path);
let mut app = App::new(options, &Config::default());
assert!(app.cycle_approval_posture());
let notices = app
.status_toasts
.iter()
.filter(|toast| toast.text.contains("moved to Ctrl+T"))
.count();
assert_eq!(notices, 1, "first cycle posts the rebinding notice");
assert!(app.cycle_approval_posture());
let notices = app
.status_toasts
.iter()
.filter(|toast| toast.text.contains("moved to Ctrl+T"))
.count();
assert_eq!(notices, 1, "notice is one-shot per session");
}
#[test]
fn plan_permission_cycle_is_rejected_without_mutating_agent_baseline() {
let _env_lock = lock_test_env();
let tmp = tempfile::tempdir().expect("tempdir");
let config_path = tmp.path().join("config.toml");
let _config_env = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
let mut options = test_options(false);
options.config_path = Some(config_path);
let mut app = App::new(options, &Config::default());
app.set_agent_approval_posture(ApprovalMode::Auto);
app.set_mode(AppMode::Plan);
assert!(!app.cycle_approval_posture());
assert_eq!(app.approval_mode, ApprovalMode::Suggest);
assert_eq!(app.mode_prefs.agent_approval_mode, ApprovalMode::Auto);
assert!(!tmp.path().join("settings.toml").exists());
assert!(
app.status_toasts
.iter()
.any(|toast| toast.text.contains("Read Only"))
);
app.set_mode(AppMode::Operate);
assert_eq!(app.approval_mode, ApprovalMode::Auto);
}
#[test]
fn busy_permission_cycle_changes_neither_runtime_nor_persistence() {
let _env_lock = lock_test_env();
let tmp = tempfile::tempdir().expect("tempdir");
let config_path = tmp.path().join("config.toml");
let _config_env = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
let mut options = test_options(false);
options.config_path = Some(config_path);
let mut app = App::new(options, &Config::default());
let before = app.approval_mode;
app.is_loading = true;
assert!(!app.cycle_approval_posture());
assert_eq!(app.approval_mode, before);
assert_eq!(app.mode_prefs.agent_approval_mode, before);
assert!(!tmp.path().join("settings.toml").exists());
assert!(
app.status_message
.as_deref()
.is_some_and(|message| message.contains("locked"))
);
}
#[test]
fn permission_postures_persist_across_restart() {
let _env_lock = lock_test_env();
for (cycles, expected) in [
(1, ApprovalMode::Auto),
(2, ApprovalMode::Bypass),
(3, ApprovalMode::Suggest),
] {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("config.toml");
let config_env = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &path);
let mut options = test_options(false);
options.start_in_agent_mode = true;
options.config_path = Some(path.clone());
let mut app = App::new(options.clone(), &Config::default());
for _ in 0..cycles {
assert!(app.cycle_approval_posture());
}
assert_eq!(app.approval_mode, expected);
assert_eq!(app.trust_mode, expected == ApprovalMode::Bypass);
let restarted = App::new(options, &Config::default());
assert_eq!(restarted.approval_mode, expected);
assert_eq!(restarted.mode_prefs.agent_approval_mode, expected);
assert_eq!(restarted.trust_mode, expected == ApprovalMode::Bypass);
drop(config_env);
}
}
#[test]
fn shift_tab_migrates_user_root_policy_to_durable_tui_posture() {
let _env_lock = lock_test_env();
let tmp = tempfile::tempdir().expect("tempdir");
let config_path = tmp.path().join("config.toml");
let settings_path = tmp.path().join("settings.toml");
std::fs::write(&config_path, "# keep\napproval_policy = \"on-request\"\n")
.expect("root config");
std::fs::write(&settings_path, "permission_posture = \"full-access\"\n").expect("settings");
let _config_env = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
let _approval_env = EnvVarGuard::remove("DEEPSEEK_APPROVAL_POLICY");
let config = Config::load(Some(config_path.clone()), None).expect("load config");
let mut options = test_options(false);
options.start_in_agent_mode = true;
options.config_path = Some(config_path.clone());
let mut app = App::new(options.clone(), &config);
assert_eq!(app.approval_mode, ApprovalMode::Suggest);
assert!(app.approval_policy_locked());
assert!(app.cycle_root_approval_posture());
assert_eq!(app.approval_mode, ApprovalMode::Auto);
assert!(!app.approval_policy_locked());
let saved_config = std::fs::read_to_string(&config_path).expect("saved config");
assert!(saved_config.contains("# keep"));
assert!(!saved_config.contains("approval_policy"));
let saved_settings = std::fs::read_to_string(&settings_path).expect("saved settings");
assert!(saved_settings.contains("permission_posture = \"auto-review\""));
let restarted_config = Config::load(Some(config_path), None).expect("reload config");
let restarted = App::new(options, &restarted_config);
assert_eq!(restarted.approval_mode, ApprovalMode::Auto);
assert!(!restarted.approval_policy_locked());
}
#[test]
fn legacy_yolo_migrates_root_policy_to_agent_full_access() {
let _env_lock = lock_test_env();
let tmp = tempfile::tempdir().expect("tempdir");
let config_path = tmp.path().join("config.toml");
let settings_path = tmp.path().join("settings.toml");
let workspace = tmp.path().join("workspace");
std::fs::create_dir_all(&workspace).expect("workspace");
std::fs::write(&config_path, "# keep\napproval_policy = \"on-request\"\n")
.expect("legacy config");
std::fs::write(&settings_path, "default_mode = \"yolo\"\n").expect("legacy settings");
let _config_env = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
let _approval_env = EnvVarGuard::remove("DEEPSEEK_APPROVAL_POLICY");
let config = Config::load(Some(config_path.clone()), None).expect("load config");
let mut options = test_options(false);
options.start_in_agent_mode = false;
options.workspace = workspace;
options.config_path = Some(config_path.clone());
let app = App::new(options.clone(), &config);
assert_eq!(app.mode, AppMode::Agent);
assert_eq!(app.approval_mode, ApprovalMode::Bypass);
assert!(!app.approval_policy_locked());
let saved_config = std::fs::read_to_string(&config_path).expect("saved config");
assert!(saved_config.contains("# keep"));
assert!(!saved_config.contains("approval_policy"));
let saved_settings = std::fs::read_to_string(&settings_path).expect("saved settings");
assert!(saved_settings.contains("default_mode = \"agent\""));
assert!(saved_settings.contains("permission_posture = \"full-access\""));
let restarted_config = Config::load(Some(config_path), None).expect("reload config");
let restarted = App::new(options, &restarted_config);
assert_eq!(restarted.mode, AppMode::Agent);
assert_eq!(restarted.approval_mode, ApprovalMode::Bypass);
assert!(!restarted.approval_policy_locked());
}
#[test]
fn legacy_yolo_honors_a_missing_explicit_config_path_without_home_fallback() {
let _env_lock = lock_test_env();
let tmp = tempfile::tempdir().expect("tempdir");
let home = tmp.path().join("home");
let home_config_dir = home.join(codewhale_config::CODEWHALE_APP_DIR);
let override_dir = tmp.path().join("missing-override");
let missing_override = override_dir.join("config.toml");
let workspace = tmp.path().join("workspace");
std::fs::create_dir_all(&home_config_dir).expect("home config dir");
std::fs::create_dir_all(&override_dir).expect("override dir");
std::fs::create_dir_all(&workspace).expect("workspace");
let home_config = home_config_dir.join("config.toml");
std::fs::write(
&home_config,
"# actual fallback\napproval_policy = \"on-request\"\n",
)
.expect("home config");
let override_settings = override_dir.join("settings.toml");
std::fs::write(&override_settings, "default_mode = \"yolo\"\n").expect("legacy settings");
let _home = EnvVarGuard::set("HOME", &home);
let _user_profile = EnvVarGuard::set("USERPROFILE", &home);
let _codewhale_home = EnvVarGuard::remove("CODEWHALE_HOME");
let _codewhale_config = EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
let _deepseek_config = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &missing_override);
let _approval_env = EnvVarGuard::remove("DEEPSEEK_APPROVAL_POLICY");
let config = Config::load(None, None).expect("load explicit missing config");
assert_eq!(config.approval_policy, None);
let mut options = test_options(false);
options.start_in_agent_mode = false;
options.workspace = workspace;
options.config_path = None;
let app = App::new(options, &config);
assert_eq!(app.mode, AppMode::Agent);
assert_eq!(app.approval_mode, ApprovalMode::Bypass);
assert!(!app.approval_policy_locked());
assert!(
!missing_override.exists(),
"settings migration must not create an unrelated config document"
);
let saved_home_config = std::fs::read_to_string(&home_config).expect("untouched home config");
assert!(saved_home_config.contains("# actual fallback"));
assert!(saved_home_config.contains("approval_policy = \"on-request\""));
let saved_settings =
std::fs::read_to_string(&override_settings).expect("normalized override settings");
assert!(saved_settings.contains("default_mode = \"agent\""));
assert!(saved_settings.contains("permission_posture = \"full-access\""));
}
#[test]
fn managed_requirements_ignore_saved_full_access_and_lock_changes() {
let _env_lock = lock_test_env();
let tmp = tempfile::tempdir().expect("tempdir");
let config_path = tmp.path().join("config.toml");
let requirements_path = tmp.path().join("requirements.toml");
std::fs::write(
tmp.path().join("settings.toml"),
"permission_posture = \"full-access\"\n",
)
.expect("settings");
std::fs::write(
&requirements_path,
"allowed_approval_policies = [\"on-request\"]\n",
)
.expect("requirements");
let _config_env = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
let config = Config {
requirements_path: Some(requirements_path.to_string_lossy().into_owned()),
..Config::default()
};
let mut app = App::new(test_options(false), &config);
assert!(app.approval_policy_locked());
assert!(app.approval_policy_requirements_managed());
assert_eq!(app.approval_mode, ApprovalMode::Suggest);
assert!(!app.cycle_approval_posture());
assert_eq!(app.approval_mode, ApprovalMode::Suggest);
assert!(
app.status_toasts
.iter()
.any(|toast| toast.text.contains("controlled"))
);
}
#[test]
fn set_mode_agent_to_yolo_to_agent_restores_baseline_without_yolo_leak() {
let mut options = test_options(false);
options.allow_shell = false;
options.start_in_agent_mode = true;
let mut app = App::new(options, &Config::default());
app.allow_shell = true;
app.trust_mode = false;
app.approval_mode = ApprovalMode::Suggest;
app.yolo_compat_notified = true;
app.set_mode(AppMode::Yolo);
assert!(app.allow_shell);
assert!(app.trust_mode);
assert_eq!(app.approval_mode, ApprovalMode::Bypass);
assert!(app.yolo);
app.set_mode(AppMode::Agent);
assert_eq!(app.mode, AppMode::Agent);
assert!(app.allow_shell, "shell baseline preserved");
assert!(
!app.trust_mode,
"YOLO trust authority must not leak into Agent"
);
assert_eq!(
app.approval_mode,
ApprovalMode::Suggest,
"YOLO Auto approvals must not leak into Agent"
);
assert!(!app.yolo);
}
#[test]
fn set_mode_plan_to_yolo_to_agent_does_not_bleed_yolo_into_agent() {
let mut options = test_options(false);
options.allow_shell = false;
options.start_in_agent_mode = true;
let mut app = App::new(options, &Config::default());
app.allow_shell = false;
app.trust_mode = false;
app.approval_mode = ApprovalMode::Never;
app.yolo_compat_notified = true;
app.set_mode(AppMode::Plan);
assert!(!app.allow_shell);
assert!(!app.trust_mode);
assert_eq!(app.approval_mode, ApprovalMode::Suggest);
app.set_mode(AppMode::Yolo);
assert!(app.allow_shell);
assert!(app.trust_mode);
assert_eq!(app.approval_mode, ApprovalMode::Bypass);
app.set_mode(AppMode::Agent);
assert_eq!(app.mode, AppMode::Agent);
assert!(!app.allow_shell);
assert!(!app.trust_mode);
assert_eq!(app.approval_mode, ApprovalMode::Never);
}
#[test]
fn set_mode_captures_agent_edits_as_the_durable_baseline() {
let mut options = test_options(false);
options.allow_shell = false;
options.start_in_agent_mode = true;
let mut app = App::new(options, &Config::default());
assert_eq!(app.mode, AppMode::Agent);
app.allow_shell = false;
app.set_agent_approval_posture(ApprovalMode::Suggest);
app.set_mode(AppMode::Plan);
app.set_mode(AppMode::Agent);
assert!(!app.allow_shell);
assert_eq!(app.approval_mode, ApprovalMode::Suggest);
app.allow_shell = true;
app.approval_mode = ApprovalMode::Never;
app.set_mode(AppMode::Plan);
assert!(!app.allow_shell, "Plan is read-only");
app.set_mode(AppMode::Agent);
assert!(app.allow_shell, "edited shell baseline restored");
assert_eq!(app.approval_mode, ApprovalMode::Never);
}
#[test]
fn yolo_start_with_default_config_restores_interactive_agent_shell_baseline() {
let _env_lock = lock_test_env();
let tmp = tempfile::tempdir().expect("tempdir");
let config_path = tmp.path().join("config.toml");
let _config_env = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
let mut options = test_options(true);
options.config_path = Some(config_path);
let mut app = App::new(options, &Config::default());
assert_eq!(app.mode, AppMode::Agent);
assert!(app.yolo);
assert!(app.allow_shell);
assert!(app.trust_mode);
assert_eq!(app.approval_mode, ApprovalMode::Bypass);
app.set_mode(AppMode::Agent);
assert!(
app.allow_shell,
"default interactive Agent baseline should expose approval-gated shell after YOLO downshift"
);
assert!(!app.trust_mode);
assert_eq!(app.approval_mode, ApprovalMode::Suggest);
}
#[test]
fn leaving_yolo_after_startup_restores_baseline_policies() {
let _env_lock = lock_test_env();
let tmp = tempfile::tempdir().expect("tempdir");
let config_path = tmp.path().join("config.toml");
let _config_env = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
let config = Config {
allow_shell: Some(false),
..Default::default()
};
let mut options = test_options(true);
options.config_path = Some(config_path);
let mut app = App::new(options, &config);
assert_eq!(app.mode, AppMode::Agent);
assert!(app.yolo);
assert!(app.allow_shell);
assert!(app.trust_mode);
assert_eq!(app.approval_mode, ApprovalMode::Bypass);
app.set_mode(AppMode::Agent);
assert!(!app.allow_shell);
assert!(!app.trust_mode);
assert_eq!(app.approval_mode, ApprovalMode::Suggest);
}
#[test]
fn configured_approval_policy_initializes_live_approval_mode() {
let config = Config {
approval_policy: Some("never".to_string()),
..Default::default()
};
let mut options = test_options(false);
options.start_in_agent_mode = true;
let app = App::new(options, &config);
assert_eq!(app.mode, AppMode::Agent);
assert_eq!(app.approval_mode, ApprovalMode::Never);
}
#[test]
fn test_mark_history_updated() {
let mut app = App::new(test_options(false), &Config::default());
let initial_version = app.history_version;
app.mark_history_updated();
assert!(app.history_version > initial_version);
}
#[test]
fn live_motion_invalidation_only_bumps_live_transcript_rows() {
let mut app = App::new(test_options(false), &Config::default());
app.history = vec![
HistoryCell::Assistant {
content: "settled".to_string(),
streaming: false,
},
HistoryCell::Assistant {
content: "streaming".to_string(),
streaming: true,
},
HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: "read_file".to_string(),
status: ToolStatus::Running,
input_summary: None,
output: None,
prompts: None,
spillover_path: None,
output_summary: None,
is_diff: false,
})),
HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: "agent".to_string(),
status: ToolStatus::Running,
input_summary: Some("action: spawn".to_string()),
output: None,
prompts: None,
spillover_path: None,
output_summary: None,
is_diff: false,
})),
HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: "read_file".to_string(),
status: ToolStatus::Success,
input_summary: None,
output: Some("done".to_string()),
prompts: None,
spillover_path: None,
output_summary: None,
is_diff: false,
})),
];
app.resync_history_revisions();
let history_before = app.history_revisions.clone();
let active = app.active_cell.get_or_insert_with(ActiveCell::new);
active.push_untracked(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: "web_search".to_string(),
status: ToolStatus::Running,
input_summary: None,
output: None,
prompts: None,
spillover_path: None,
output_summary: None,
is_diff: false,
})));
let app_active_before = app.active_cell_revision;
let cell_active_before = app.active_cell.as_ref().expect("active cell").revision();
app.mark_live_motion_updated();
assert_eq!(app.history_revisions[0], history_before[0]);
assert_ne!(app.history_revisions[1], history_before[1]);
assert_ne!(app.history_revisions[2], history_before[2]);
assert_eq!(app.history_revisions[3], history_before[3]);
assert_eq!(app.history_revisions[4], history_before[4]);
assert_ne!(app.active_cell_revision, app_active_before);
assert_ne!(
app.active_cell.as_ref().expect("active cell").revision(),
cell_active_before
);
let history_after_all_live = app.history_revisions.clone();
let app_active_after_all_live = app.active_cell_revision;
let cell_active_after_all_live = app.active_cell.as_ref().expect("active cell").revision();
app.mark_live_history_motion_updated();
assert_eq!(app.history_revisions[0], history_after_all_live[0]);
assert_ne!(app.history_revisions[1], history_after_all_live[1]);
assert_ne!(app.history_revisions[2], history_after_all_live[2]);
assert_eq!(app.history_revisions[3], history_after_all_live[3]);
assert_eq!(app.history_revisions[4], history_after_all_live[4]);
assert_eq!(app.active_cell_revision, app_active_after_all_live);
assert_eq!(
app.active_cell.as_ref().expect("active cell").revision(),
cell_active_after_all_live
);
}
#[test]
fn expanded_tool_runs_rebase_when_history_prefix_shifts() {
let mut app = App::new(test_options(false), &Config::default());
app.expanded_tool_runs = std::collections::HashSet::from([2usize, 6usize]);
app.shift_history_maps_down(3);
assert_eq!(app.expanded_tool_runs, std::collections::HashSet::from([3]));
}
#[test]
fn expanded_tool_runs_prune_when_history_is_truncated() {
let mut app = App::new(test_options(false), &Config::default());
for idx in 0..5 {
app.add_message(HistoryCell::System {
content: format!("cell {idx}"),
});
}
app.expanded_tool_runs = std::collections::HashSet::from([1usize, 4usize]);
app.truncate_history_to(3);
assert_eq!(app.expanded_tool_runs, std::collections::HashSet::from([1]));
}
#[test]
fn tool_run_expansion_toggle_opens_and_closes_run() {
let mut app = App::new(test_options(false), &Config::default());
app.tool_collapse_mode = ToolCollapseMode::Compact;
app.tool_collapse_threshold = 3;
for name in ["read_file", "list_dir", "web_search"] {
app.add_message(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: name.to_string(),
status: ToolStatus::Success,
input_summary: None,
output: Some("ok".to_string()),
prompts: None,
spillover_path: None,
output_summary: None,
is_diff: false,
})));
}
assert!(app.toggle_tool_run_expansion_at(0));
assert!(app.expanded_tool_runs.contains(&0));
assert!(app.toggle_tool_run_expansion_at(2));
assert!(!app.expanded_tool_runs.contains(&0));
assert!(!app.toggle_tool_run_expansion_at(99));
}
#[test]
fn tool_run_expansion_toggle_handles_active_run() {
let mut app = App::new(test_options(false), &Config::default());
app.tool_collapse_mode = ToolCollapseMode::Compact;
app.tool_collapse_threshold = 3;
app.add_message(HistoryCell::User {
content: "go".to_string(),
});
let active_start = app.history.len();
let active = app.active_cell.get_or_insert_with(ActiveCell::new);
for name in ["read_file", "list_dir", "web_search"] {
active.push_untracked(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: name.to_string(),
status: ToolStatus::Success,
input_summary: None,
output: Some("ok".to_string()),
prompts: None,
spillover_path: None,
output_summary: None,
is_diff: false,
})));
}
assert!(app.toggle_tool_run_expansion_at(active_start));
assert!(app.expanded_tool_runs.contains(&active_start));
assert!(app.toggle_tool_run_expansion_at(active_start + 2));
assert!(!app.expanded_tool_runs.contains(&active_start));
}
#[test]
fn test_scroll_operations() {
let mut app = App::new(test_options(false), &Config::default());
app.scroll_up(5);
app.scroll_down(3);
}
#[test]
fn resize_preserves_scrolled_transcript_position() {
let mut app = App::new(test_options(false), &Config::default());
app.viewport.transcript_scroll = TranscriptScroll::at_line(42);
app.viewport.last_transcript_top = 42;
app.viewport.pending_scroll_delta = 5;
app.handle_resize(120, 40);
let meta = vec![
TranscriptLineMeta::Spacer {
copy_prefix_width: 0
};
240
];
let (_, top) = app.viewport.transcript_scroll.resolve_top(&meta, 200);
assert_eq!(top, 42);
assert_eq!(app.viewport.pending_scroll_delta, 0);
}
#[test]
fn resize_keeps_tail_state_when_user_was_at_tail() {
let mut app = App::new(test_options(false), &Config::default());
app.viewport.transcript_scroll = TranscriptScroll::to_bottom();
app.viewport.last_transcript_top = 42;
app.handle_resize(120, 40);
assert!(app.viewport.transcript_scroll.is_at_tail());
}
#[test]
fn resize_seeds_visible_height_for_paging_before_next_render() {
let mut app = App::new(test_options(false), &Config::default());
app.viewport.last_transcript_visible = 12;
app.handle_resize(120, 40);
assert_eq!(app.viewport.last_transcript_visible, 38);
app.handle_resize(120, 1);
assert_eq!(app.viewport.last_transcript_visible, 1);
}
#[test]
fn test_add_message() {
let mut app = App::new(test_options(false), &Config::default());
let initial_len = app.history.len();
app.add_message(HistoryCell::User {
content: "test".to_string(),
});
assert_eq!(app.history.len(), initial_len + 1);
}
#[test]
fn test_compaction_config() {
let mut app = App::new(test_options(false), &Config::default());
let config = app.compaction_config();
let _ = config.enabled;
app.auto_model = true;
app.model = "auto".to_string();
app.last_effective_model = None;
let config = app.compaction_config();
assert_eq!(config.model, DEFAULT_TEXT_MODEL);
app.last_effective_model = Some("deepseek-v4-flash".to_string());
let config = app.compaction_config();
assert_eq!(config.model, "deepseek-v4-flash");
}
#[test]
fn test_update_model_compaction_budget() {
let mut app = App::new(test_options(false), &Config::default());
app.auto_model = false;
app.api_provider = ApiProvider::Deepseek;
app.active_route_limits = None;
app.active_context_window_override = None;
app.auto_compact_threshold_percent = 80.0;
app.model = "deepseek-v4-pro".to_string();
app.update_model_compaction_budget();
let large_window_threshold = app.compact_threshold;
app.model = "unknown-test-model".to_string();
app.update_model_compaction_budget();
let unknown_threshold = app.compact_threshold;
assert!(
unknown_threshold > 0,
"unknown model must still get a positive budget"
);
assert!(
large_window_threshold > unknown_threshold,
"a large-context model ({large_window_threshold}) should budget more \
than an unknown model ({unknown_threshold})"
);
}
#[test]
fn test_input_history_navigation() {
let mut app = App::new(test_options(false), &Config::default());
app.input_history.push("first".to_string());
app.input_history.push("second".to_string());
app.history_up();
assert!(app.history_index.is_some());
app.history_down();
}
#[test]
fn input_history_down_restores_live_draft_after_accidental_up() {
let mut app = App::new(test_options(false), &Config::default());
app.input_history.push("previous prompt".to_string());
app.input = "careful current draft".to_string();
app.cursor_position = "careful".chars().count();
app.history_up();
assert_eq!(app.input, "previous prompt");
app.history_down();
assert_eq!(app.input, "careful current draft");
assert_eq!(app.cursor_position, "careful".chars().count());
assert!(app.history_index.is_none());
}
#[test]
fn input_history_navigation_clears_stale_selection() {
let mut app = App::new(test_options(false), &Config::default());
app.input_history.push("previous input".to_string());
app.input = "hello world".to_string();
app.cursor_position = "hello ".chars().count();
app.selection_anchor = Some(app.input.chars().count());
app.history_up();
assert_eq!(app.input, "previous input");
assert!(app.selection_anchor.is_none());
app.insert_char('x');
assert_eq!(app.input, "previous inputx");
}
#[test]
fn input_history_restores_empty_draft_at_end_of_navigation() {
let mut app = App::new(test_options(false), &Config::default());
app.input_history.push("previous prompt".to_string());
app.history_up();
assert_eq!(app.input, "previous prompt");
app.history_down();
assert!(app.input.is_empty());
assert_eq!(app.cursor_position, 0);
assert!(app.history_index.is_none());
}
#[test]
fn word_cursor_helpers_move_by_whitespace_delimited_words() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "alpha beta gamma".to_string();
app.cursor_position = 0;
app.move_cursor_word_forward();
assert_eq!(app.cursor_position, "alpha ".chars().count());
app.move_cursor_word_forward();
assert_eq!(app.cursor_position, "alpha beta ".chars().count());
app.move_cursor_word_backward();
assert_eq!(app.cursor_position, "alpha ".chars().count());
}
#[test]
fn editing_history_entry_leaves_navigation_mode() {
let mut app = App::new(test_options(false), &Config::default());
app.input_history.push("previous prompt".to_string());
app.input = "current draft".to_string();
app.cursor_position = app.input.chars().count();
app.history_up();
app.insert_char('!');
app.history_down();
assert_eq!(app.input, "previous prompt!");
assert!(app.history_index.is_none());
}
#[test]
fn history_search_filters_matches_and_skips_duplicates() {
let mut app = App::new(test_options(false), &Config::default());
app.input_history.clear();
app.input_history.push("alpha one".to_string());
app.input_history.push("beta two".to_string());
app.input_history.push("alpha one".to_string());
app.draft_history.push_back("draft alpha".to_string());
app.start_history_search();
app.history_search_insert_str("alpha");
assert_eq!(
app.history_search_matches(),
vec!["draft alpha".to_string(), "alpha one".to_string()]
);
}
#[test]
fn history_search_matches_unicode_case_insensitively() {
let mut app = App::new(test_options(false), &Config::default());
app.input_history.clear();
app.input_history.push("CAFÉ prompt".to_string());
app.start_history_search();
app.history_search_insert_str("café");
assert_eq!(
app.history_search_matches(),
vec!["CAFÉ prompt".to_string()]
);
}
#[test]
fn history_search_accepts_match_without_submitting() {
let mut app = App::new(test_options(false), &Config::default());
app.input_history.clear();
app.input_history.push("older prompt".to_string());
app.start_history_search();
app.history_search_insert_str("older");
assert!(app.accept_history_search());
assert_eq!(app.input, "older prompt");
assert_eq!(app.cursor_position, "older prompt".chars().count());
assert!(app.composer_history_search.is_none());
}
#[test]
fn history_search_cancel_restores_pre_search_draft() {
let mut app = App::new(test_options(false), &Config::default());
app.input_history.clear();
app.input = "current draft".to_string();
app.cursor_position = 7;
app.input_history.push("older prompt".to_string());
app.start_history_search();
app.history_search_insert_str("older");
app.cancel_history_search();
assert_eq!(app.input, "current draft");
assert_eq!(app.cursor_position, 7);
assert!(app.composer_history_search.is_none());
}
#[test]
fn recoverable_clear_stashes_nonempty_draft() {
let mut app = App::new(test_options(false), &Config::default());
app.input_history.clear();
app.input = "recover this".to_string();
app.cursor_position = app.input.chars().count();
app.clear_input_recoverable();
app.start_history_search();
app.history_search_insert_str("recover");
assert_eq!(
app.history_search_matches(),
vec!["recover this".to_string()]
);
}
#[test]
fn clear_undo_buffer_is_set_on_clear_input_recoverable() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "hello".to_string();
app.cursor_position = 5;
app.clear_input_recoverable();
assert!(app.input.is_empty());
assert_eq!(app.clear_undo_buffer.as_deref(), Some("hello"));
}
#[test]
fn clear_undo_buffer_is_none_when_clearing_empty_input() {
let mut app = App::new(test_options(false), &Config::default());
assert!(app.input.is_empty());
app.clear_input_recoverable();
assert!(app.clear_undo_buffer.is_none());
}
#[test]
fn restore_last_cleared_input_restores_saved_draft() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "previous".to_string();
app.cursor_position = 8;
app.clear_input_recoverable();
assert!(app.input.is_empty());
let restored = app.restore_last_cleared_input_if_empty();
assert!(restored);
assert_eq!(app.input, "previous");
assert!(app.clear_undo_buffer.is_none());
}
#[test]
fn restore_last_cleared_input_does_nothing_when_composer_not_empty() {
let mut app = App::new(test_options(false), &Config::default());
app.clear_undo_buffer = Some("old".to_string());
app.input = "current".to_string();
assert!(!app.restore_last_cleared_input_if_empty());
}
#[test]
fn composer_paste_flushes_pending_burst_and_normalizes_crlf() {
let mut app = App::new(test_options(false), &Config::default());
app.use_paste_burst_detection = true;
let now = Instant::now();
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('x'),
crossterm::event::KeyModifiers::NONE,
);
assert!(crate::tui::paste::handle_paste_burst_key(
&mut app, &key, now
));
assert!(
app.input.is_empty(),
"first burst char should stay buffered"
);
app.insert_paste_text("a\r\nb\rc");
assert_eq!(app.input, "xa\nb\nc");
assert_eq!(app.cursor_position, "xa\nb\nc".chars().count());
assert!(!app.paste_burst.is_active());
}
#[test]
fn bracketed_paste_preserves_bare_carriage_return_line_breaks() {
let mut app = App::new(test_options(false), &Config::default());
app.insert_paste_text("alpha\r indented\r# literal heading\r- literal list");
assert_eq!(
app.input,
"alpha\n indented\n# literal heading\n- literal list"
);
assert_eq!(app.cursor_position, app.input.chars().count());
}
#[test]
fn enter_during_active_paste_burst_appends_newline_to_buffer_not_submit() {
let mut app = App::new(test_options(false), &Config::default());
app.use_paste_burst_detection = true;
let now = Instant::now();
app.paste_burst.append_char_to_buffer('h', now);
app.paste_burst.append_char_to_buffer('i', now);
assert!(app.paste_burst.is_active());
assert!(app.input.is_empty());
let result = app.handle_composer_enter();
assert!(
result.is_none(),
"Enter during active paste burst must not submit"
);
let flushed = app.paste_burst.flush_before_modified_input();
assert_eq!(
flushed.as_deref(),
Some("hi\n"),
"newline must land in the burst buffer so the next flush carries it"
);
}
#[test]
fn enter_inside_paste_burst_window_after_flush_inserts_newline_not_submit() {
let mut app = App::new(test_options(false), &Config::default());
app.use_paste_burst_detection = true;
app.input = "hello".to_string();
app.cursor_position = "hello".chars().count();
let now = Instant::now();
app.paste_burst.extend_window(now);
assert!(!app.paste_burst.is_active());
assert!(
app.paste_burst.newline_should_insert_instead_of_submit(now),
"suppression window should be open"
);
let result = app.handle_composer_enter();
assert!(
result.is_none(),
"Enter inside post-flush suppression window must not submit"
);
assert_eq!(
app.input, "hello\n",
"newline must be inserted into the composer instead of firing a submit"
);
}
#[test]
fn enter_absorbed_after_flush_does_not_re_arm_the_suppression_window() {
let mut app = App::new(test_options(false), &Config::default());
app.use_paste_burst_detection = true;
app.input = "hello".to_string();
app.cursor_position = "hello".chars().count();
let now = Instant::now();
app.paste_burst.extend_window(now);
assert!(
app.handle_composer_enter().is_none(),
"first Enter is absorbed as the paste's possible trailing newline"
);
assert_eq!(app.input, "hello\n");
assert!(
!app.paste_burst
.newline_should_insert_instead_of_submit(now + Duration::from_millis(121)),
"absorbing an Enter must not extend the suppression window"
);
}
#[test]
fn enter_outside_any_paste_burst_window_submits_normally() {
let mut app = App::new(test_options(false), &Config::default());
app.use_paste_burst_detection = true;
app.input = "hello world".to_string();
app.cursor_position = "hello world".chars().count();
let result = app.handle_composer_enter();
assert_eq!(
result.as_deref(),
Some("hello world"),
"Enter outside any paste burst window must submit normally"
);
assert!(
app.input.is_empty(),
"submit_input should clear the composer"
);
}
#[test]
fn enter_with_paste_burst_detection_disabled_submits_normally() {
let mut app = App::new(test_options(false), &Config::default());
app.use_paste_burst_detection = false;
app.input = "ship it".to_string();
app.cursor_position = "ship it".chars().count();
let now = Instant::now();
app.paste_burst.extend_window(now);
let result = app.handle_composer_enter();
assert_eq!(result.as_deref(), Some("ship it"));
}
#[test]
fn clipboard_text_paste_matches_bracketed_paste_state() {
let text = "alpha\r\nbeta";
let mut bracketed = App::new(test_options(false), &Config::default());
let mut clipboard = App::new(test_options(false), &Config::default());
bracketed.insert_paste_text(text);
clipboard.apply_clipboard_content(ClipboardContent::Text(text.to_string()));
assert_eq!(clipboard.input, bracketed.input);
assert_eq!(clipboard.cursor_position, bracketed.cursor_position);
assert_eq!(clipboard.slash_menu_hidden, bracketed.slash_menu_hidden);
assert_eq!(clipboard.mention_menu_hidden, bracketed.mention_menu_hidden);
}
#[test]
fn ssh_direct_clipboard_paste_points_to_terminal_owned_bracketed_paste() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "keep this draft".to_string();
app.cursor_position = app.input.chars().count();
app.clipboard = ClipboardHandler::for_test(true, true);
assert!(!app.paste_from_clipboard());
assert_eq!(app.input, "keep this draft");
let hint = app
.status_message
.as_deref()
.expect("remote paste hint")
.to_string();
assert!(hint.contains("SSH paste uses your local terminal"));
assert!(hint.contains("Cmd+V on macOS"));
assert!(hint.contains("Ctrl+Shift+V on Linux/Windows"));
}
#[test]
fn clipboard_image_paste_keeps_adjacent_text_and_concise_status() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "before after".to_string();
app.cursor_position = "before".chars().count();
app.apply_clipboard_content(ClipboardContent::Image(PastedImage {
path: PathBuf::from("/tmp/pasted.png"),
width: 8,
height: 4,
byte_len: 2048,
}));
assert!(
app.input
.contains("before\n[Attached image: 8x4 PNG (2KB) at /tmp/pasted.png]")
);
assert!(app.input.contains("] after"));
let status = app.status_message.as_deref().expect("status message");
assert_eq!(status, "Attached image: 8x4 PNG (2KB)");
}
#[test]
fn pasted_text_and_image_placeholders_survive_history_and_queue_paths() {
let mut app = App::new(test_options(false), &Config::default());
app.insert_paste_text("line 1\r\nline 2");
app.insert_media_attachment("image", Path::new("/tmp/pasted.png"), Some("8x4 PNG (2KB)"));
let submitted = app.submit_input().expect("submitted input");
assert!(submitted.contains("line 1\nline 2"));
assert!(submitted.contains("[Attached image: 8x4 PNG (2KB) at /tmp/pasted.png]"));
app.history_up();
assert_eq!(app.input, submitted);
assert_eq!(app.composer_attachment_count(), 1);
app.clear_input();
app.queue_message(QueuedMessage::new(
submitted.clone(),
Some("Use this skill".to_string()),
));
assert!(app.pop_last_queued_into_draft());
assert_eq!(app.input, submitted);
assert_eq!(app.composer_attachment_count(), 1);
assert_eq!(
app.queued_draft
.as_ref()
.and_then(|draft| draft.skill_instruction.as_deref()),
Some("Use this skill")
);
app.push_pending_steer(QueuedMessage::new(submitted.clone(), None));
let steers = app.drain_pending_steers();
assert_eq!(steers[0].display, submitted);
}
#[test]
fn selected_attachment_row_removes_placeholder_without_manual_editing() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "before".to_string();
app.cursor_position = "before".chars().count();
app.insert_media_attachment("image", Path::new("/tmp/pasted.png"), Some("8x4 PNG"));
app.insert_str("after");
app.move_cursor_start();
assert!(app.select_previous_composer_attachment());
assert_eq!(app.selected_composer_attachment_index(), Some(0));
assert!(app.remove_selected_composer_attachment());
assert!(!app.input.contains("[Attached image:"));
assert!(app.input.contains("before"));
assert!(app.input.contains("after"));
assert_eq!(app.composer_attachment_count(), 0);
assert!(app.selected_composer_attachment_index().is_none());
}
#[test]
fn kill_to_end_of_line_cuts_from_middle_of_word() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "hello world".to_string();
app.cursor_position = 6; assert!(app.kill_to_end_of_line());
assert_eq!(app.input, "hello ");
assert_eq!(app.cursor_position, 6);
assert_eq!(app.kill_buffer, "world");
}
#[test]
fn kill_at_eol_consumes_following_newline() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "line one\nline two".to_string();
app.cursor_position = 8; assert!(app.kill_to_end_of_line());
assert_eq!(app.input, "line oneline two");
assert_eq!(app.cursor_position, 8);
assert_eq!(app.kill_buffer, "\n");
let mut empty = App::new(test_options(false), &Config::default());
assert!(!empty.kill_to_end_of_line());
assert!(empty.input.is_empty());
assert!(empty.kill_buffer.is_empty());
}
#[test]
fn yank_inserts_kill_buffer_and_preserves_it() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "abc def".to_string();
app.cursor_position = 4; assert!(app.kill_to_end_of_line());
assert_eq!(app.input, "abc ");
assert_eq!(app.kill_buffer, "def");
app.cursor_position = 0;
assert!(app.yank());
assert!(app.yank());
assert_eq!(app.input, "defdefabc ");
assert_eq!(app.cursor_position, 6);
assert_eq!(app.kill_buffer, "def");
let mut empty = App::new(test_options(false), &Config::default());
assert!(!empty.yank());
assert!(empty.input.is_empty());
}
#[test]
fn quit_is_not_armed_by_default() {
let app = App::new(test_options(false), &Config::default());
assert!(!app.quit_is_armed());
assert!(app.quit_armed_until.is_none());
}
#[test]
fn arm_quit_sets_two_second_window() {
let mut app = App::new(test_options(false), &Config::default());
app.arm_quit();
assert!(app.quit_is_armed());
let deadline = app.quit_armed_until.expect("deadline set");
let remaining = deadline.saturating_duration_since(Instant::now());
assert!(
remaining >= Duration::from_millis(1500) && remaining <= Duration::from_secs(2),
"expected ~2s window, got {remaining:?}",
);
assert!(app.needs_redraw, "armed prompt should request a redraw");
}
#[test]
fn disarm_quit_clears_the_timer() {
let mut app = App::new(test_options(false), &Config::default());
app.arm_quit();
app.needs_redraw = false;
app.disarm_quit();
assert!(!app.quit_is_armed());
assert!(app.quit_armed_until.is_none());
assert!(app.needs_redraw, "disarming should request a redraw");
}
#[test]
fn disarm_quit_when_not_armed_is_a_noop() {
let mut app = App::new(test_options(false), &Config::default());
app.needs_redraw = false;
app.disarm_quit();
assert!(!app.needs_redraw, "no redraw when nothing changed");
}
#[test]
fn quit_armed_expires_after_window() {
let mut app = App::new(test_options(false), &Config::default());
app.quit_armed_until = Some(Instant::now() - Duration::from_millis(10));
assert!(
!app.quit_is_armed(),
"expired timer must not count as armed"
);
app.needs_redraw = false;
app.tick_quit_armed();
assert!(app.quit_armed_until.is_none(), "tick clears expired timer");
assert!(
app.needs_redraw,
"expiry triggers a redraw to repaint footer"
);
}
#[test]
fn quit_armed_tick_is_noop_within_window() {
let mut app = App::new(test_options(false), &Config::default());
app.arm_quit();
app.needs_redraw = false;
app.tick_quit_armed();
assert!(
app.quit_is_armed(),
"tick within window keeps the timer armed"
);
assert!(!app.needs_redraw, "no redraw when nothing changed");
}
#[test]
fn re_arming_after_expiry_starts_a_fresh_window() {
let mut app = App::new(test_options(false), &Config::default());
app.quit_armed_until = Some(Instant::now() - Duration::from_secs(5));
app.tick_quit_armed();
assert!(app.quit_armed_until.is_none());
app.arm_quit();
let deadline = app.quit_armed_until.expect("re-armed");
assert!(deadline > Instant::now(), "fresh deadline in the future");
}
#[test]
fn submit_disposition_immediate_when_idle_and_online() {
let app = App::new(test_options(false), &Config::default());
assert!(!app.is_loading);
assert!(!app.offline_mode);
assert_eq!(
app.decide_submit_disposition(),
SubmitDisposition::Immediate
);
}
#[test]
fn submit_disposition_queue_when_busy_and_online_not_streaming() {
let mut app = App::new(test_options(false), &Config::default());
app.is_loading = true;
app.offline_mode = false;
assert_eq!(app.decide_submit_disposition(), SubmitDisposition::Queue);
}
#[test]
fn submit_disposition_queue_when_busy_and_streaming() {
let mut app = App::new(test_options(false), &Config::default());
app.is_loading = true;
app.offline_mode = false;
app.streaming_message_index = Some(0);
assert_eq!(app.decide_submit_disposition(), SubmitDisposition::Queue);
}
#[test]
fn submit_disposition_queue_when_offline_and_idle() {
let mut app = App::new(test_options(false), &Config::default());
app.is_loading = false;
app.offline_mode = true;
assert_eq!(app.decide_submit_disposition(), SubmitDisposition::Queue);
}
#[test]
fn submit_disposition_offline_busy_queues() {
let mut app = App::new(test_options(false), &Config::default());
app.is_loading = true;
app.offline_mode = true;
app.streaming_message_index = Some(0);
assert_eq!(app.decide_submit_disposition(), SubmitDisposition::Queue);
}
#[test]
fn composer_submit_state_by_chord_matrix() {
use super::{ComposerSubmitAction, ComposerSubmitChord};
let mut app = App::new(test_options(false), &Config::default());
app.input = "hello".to_string();
assert_eq!(
app.decide_composer_submit(ComposerSubmitChord::Enter),
ComposerSubmitAction::Submit(SubmitDisposition::Immediate)
);
assert_eq!(
app.decide_composer_submit(ComposerSubmitChord::CtrlEnter),
ComposerSubmitAction::Submit(SubmitDisposition::Immediate)
);
app.is_loading = true;
assert_eq!(
app.decide_composer_submit(ComposerSubmitChord::Enter),
ComposerSubmitAction::Submit(SubmitDisposition::Queue)
);
assert_eq!(
app.decide_composer_submit(ComposerSubmitChord::CtrlEnter),
ComposerSubmitAction::Submit(SubmitDisposition::Steer)
);
app.streaming_message_index = Some(0);
assert_eq!(
app.decide_composer_submit(ComposerSubmitChord::Enter),
ComposerSubmitAction::Submit(SubmitDisposition::Queue)
);
assert_eq!(
app.decide_composer_submit(ComposerSubmitChord::CtrlEnter),
ComposerSubmitAction::Submit(SubmitDisposition::Steer)
);
app.queue_message(QueuedMessage::new("older queued".to_string(), None));
app.input.clear();
assert_eq!(
app.decide_composer_submit(ComposerSubmitChord::Enter),
ComposerSubmitAction::SendQueuedNow
);
assert_eq!(
app.decide_composer_submit(ComposerSubmitChord::CtrlEnter),
ComposerSubmitAction::SendQueuedNow
);
app.input = "offline follow-up".to_string();
app.offline_mode = true;
assert_eq!(
app.decide_composer_submit(ComposerSubmitChord::CtrlEnter),
ComposerSubmitAction::Submit(SubmitDisposition::Queue)
);
}
#[test]
fn bare_enter_while_streaming_stays_queue_not_steer() {
let mut app = App::new(test_options(false), &Config::default());
app.is_loading = true;
app.streaming_message_index = Some(0);
let first = app.enter_with_double_tap();
assert_eq!(first, Some(SubmitDisposition::Queue));
let second = app.enter_with_double_tap();
assert_eq!(second, Some(SubmitDisposition::Queue));
}
#[test]
fn submit_disposition_does_not_mutate_the_queue() {
let mut app = App::new(test_options(false), &Config::default());
app.is_loading = true;
app.streaming_message_index = Some(0);
assert_eq!(app.enter_with_double_tap(), Some(SubmitDisposition::Queue));
app.queue_message(QueuedMessage::new("older queued".to_string(), None));
app.queue_message(QueuedMessage::new("just typed follow-up".to_string(), None));
assert!(app.input.is_empty());
assert_eq!(app.enter_with_double_tap(), Some(SubmitDisposition::Queue));
assert_eq!(app.queued_message_count(), 2);
}
#[test]
fn sticky_error_ttl_is_capped_and_clears_on_composer_activity() {
let mut app = App::new(test_options(false), &Config::default());
app.set_sticky_status("workflow failed", StatusToastLevel::Error, None);
let sticky = app.sticky_status.as_ref().expect("sticky error");
assert_eq!(sticky.ttl_ms, Some(App::STICKY_ERROR_TTL_MS));
app.insert_char('a');
assert!(app.sticky_status.is_none());
}
#[test]
fn bare_enter_passes_through_when_idle() {
let mut app = App::new(test_options(false), &Config::default());
let first = app.enter_with_double_tap();
assert_eq!(first, Some(SubmitDisposition::Immediate));
let second = app.enter_with_double_tap();
assert_eq!(second, Some(SubmitDisposition::Immediate));
}
#[test]
fn push_pending_steer_arms_resend_flag() {
let mut app = App::new(test_options(false), &Config::default());
assert!(!app.submit_pending_steers_after_interrupt);
app.push_pending_steer(QueuedMessage::new("steer me".to_string(), None));
assert_eq!(app.pending_steers.len(), 1);
assert!(app.submit_pending_steers_after_interrupt);
}
#[test]
fn drain_pending_steers_clears_flag_and_returns_in_order() {
let mut app = App::new(test_options(false), &Config::default());
app.push_pending_steer(QueuedMessage::new("first".to_string(), None));
app.push_pending_steer(QueuedMessage::new("second".to_string(), None));
app.push_pending_steer(QueuedMessage::new("third".to_string(), None));
let drained = app.drain_pending_steers();
assert_eq!(drained.len(), 3);
assert_eq!(drained[0].display, "first");
assert_eq!(drained[2].display, "third");
assert!(app.pending_steers.is_empty());
assert!(!app.submit_pending_steers_after_interrupt);
}
#[test]
fn drain_pending_steers_when_empty_is_safe() {
let mut app = App::new(test_options(false), &Config::default());
app.submit_pending_steers_after_interrupt = true;
let drained = app.drain_pending_steers();
assert!(drained.is_empty());
assert!(!app.submit_pending_steers_after_interrupt);
}
#[test]
fn double_push_pending_steer_is_idempotent_on_flag() {
let mut app = App::new(test_options(false), &Config::default());
app.push_pending_steer(QueuedMessage::new("a".to_string(), None));
app.push_pending_steer(QueuedMessage::new("b".to_string(), None));
assert!(app.submit_pending_steers_after_interrupt);
assert_eq!(app.pending_steers.len(), 2);
}
#[test]
fn pop_last_queued_into_draft_pops_back_and_arms_draft() {
let mut app = App::new(test_options(false), &Config::default());
app.queue_message(QueuedMessage::new(
"first".to_string(),
Some("skill-A".to_string()),
));
app.queue_message(QueuedMessage::new(
"last".to_string(),
Some("skill-B".to_string()),
));
assert!(app.pop_last_queued_into_draft());
assert_eq!(app.input, "last");
assert_eq!(app.cursor_position, "last".chars().count());
assert_eq!(app.queued_messages.len(), 1);
let draft = app.queued_draft.clone().expect("draft is set");
assert_eq!(draft.display, "last");
assert_eq!(draft.skill_instruction.as_deref(), Some("skill-B"));
}
#[test]
fn pop_last_queued_into_draft_noop_when_composer_dirty() {
let mut app = App::new(test_options(false), &Config::default());
app.queue_message(QueuedMessage::new("queued".to_string(), None));
app.input = "typing".to_string();
app.cursor_position = char_count(&app.input);
assert!(!app.pop_last_queued_into_draft());
assert_eq!(app.input, "typing");
assert_eq!(app.queued_messages.len(), 1);
assert!(app.queued_draft.is_none());
}
#[test]
fn pop_last_queued_into_draft_noop_when_draft_already_armed() {
let mut app = App::new(test_options(false), &Config::default());
app.queue_message(QueuedMessage::new("queued".to_string(), None));
app.queued_draft = Some(QueuedMessage::new("editing".to_string(), None));
assert!(!app.pop_last_queued_into_draft());
assert_eq!(app.queued_messages.len(), 1);
assert_eq!(
app.queued_draft.as_ref().map(|d| d.display.as_str()),
Some("editing")
);
}
#[test]
fn pop_last_queued_into_draft_noop_when_queue_empty() {
let mut app = App::new(test_options(false), &Config::default());
assert!(!app.pop_last_queued_into_draft());
assert!(app.input.is_empty());
assert!(app.queued_draft.is_none());
}
#[test]
fn cancel_queued_draft_edit_restores_original_message() {
let mut app = App::new(test_options(false), &Config::default());
app.queue_message(QueuedMessage::new("first".to_string(), None));
app.queue_message(QueuedMessage::new(
"original follow-up".to_string(),
Some("skill".to_string()),
));
assert!(app.pop_last_queued_into_draft());
app.input = "edited but not submitted".to_string();
app.cursor_position = char_count(&app.input);
assert!(app.cancel_queued_draft_edit());
assert!(app.input.is_empty());
assert!(app.queued_draft.is_none());
assert_eq!(app.queued_messages.len(), 2);
let restored = app.queued_messages.back().expect("restored message");
assert_eq!(restored.display, "original follow-up");
assert_eq!(restored.skill_instruction.as_deref(), Some("skill"));
assert_eq!(
app.clear_undo_buffer.as_deref(),
Some("edited but not submitted"),
"the interrupted edit remains recoverable via normal draft recovery"
);
}
#[test]
fn finalize_streaming_assistant_marks_existing_cell_interrupted() {
let mut app = App::new(test_options(false), &Config::default());
app.add_message(HistoryCell::Assistant {
content: "partial reply so far".to_string(),
streaming: true,
});
let idx = app.history.len() - 1;
app.streaming_message_index = Some(idx);
app.finalize_streaming_assistant_as_interrupted();
assert!(app.streaming_message_index.is_none());
match &app.history[idx] {
HistoryCell::Assistant { content, streaming } => {
assert!(content.starts_with("[interrupted]"), "got: {content}");
assert!(content.contains("partial reply so far"));
assert!(!*streaming);
}
other => panic!("expected Assistant cell, got {other:?}"),
}
}
#[test]
fn finalize_streaming_assistant_handles_empty_content() {
let mut app = App::new(test_options(false), &Config::default());
app.add_message(HistoryCell::Assistant {
content: String::new(),
streaming: true,
});
let idx = app.history.len() - 1;
app.streaming_message_index = Some(idx);
app.finalize_streaming_assistant_as_interrupted();
match &app.history[idx] {
HistoryCell::Assistant { content, streaming } => {
assert_eq!(content, "[interrupted]");
assert!(!*streaming);
}
other => panic!("expected Assistant cell, got {other:?}"),
}
}
#[test]
fn finalize_streaming_assistant_no_op_without_index() {
let mut app = App::new(test_options(false), &Config::default());
let prev_len = app.history.len();
app.finalize_streaming_assistant_as_interrupted();
assert_eq!(app.history.len(), prev_len);
assert!(app.streaming_message_index.is_none());
}
#[test]
fn finalize_streaming_assistant_is_idempotent_on_double_call() {
let mut app = App::new(test_options(false), &Config::default());
app.add_message(HistoryCell::Assistant {
content: "something".to_string(),
streaming: true,
});
let idx = app.history.len() - 1;
app.streaming_message_index = Some(idx);
app.finalize_streaming_assistant_as_interrupted();
app.finalize_streaming_assistant_as_interrupted();
match &app.history[idx] {
HistoryCell::Assistant { content, .. } => {
assert!(content.starts_with("[interrupted] "));
assert_eq!(content.matches("[interrupted]").count(), 1);
}
other => panic!("expected Assistant cell, got {other:?}"),
}
}
#[test]
fn delete_word_backward_removes_previous_word_only() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "hello world".to_string();
app.cursor_position = char_count(&app.input);
app.delete_word_backward();
assert_eq!(app.input, "hello ");
assert_eq!(app.cursor_position, char_count("hello "));
}
#[test]
fn delete_word_backward_handles_trailing_space_and_utf8() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "cafe 你好 ".to_string();
app.cursor_position = char_count(&app.input);
app.delete_word_backward();
assert_eq!(app.input, "cafe ");
assert_eq!(app.cursor_position, char_count("cafe "));
}
#[test]
fn delete_word_forward_handles_leading_space_and_utf8() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "hello 你好 world".to_string();
app.cursor_position = char_count("hello");
app.delete_word_forward();
assert_eq!(app.input, "hello world");
assert_eq!(app.cursor_position, char_count("hello"));
}
#[test]
fn delete_to_start_of_line_respects_multiline_cursor() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "first\nsecond line".to_string();
app.cursor_position = char_count("first\nsecond");
app.delete_to_start_of_line();
assert_eq!(app.input, "first\n line");
assert_eq!(app.cursor_position, char_count("first\n"));
}
#[test]
fn kill_and_yank_handle_multibyte_utf8() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "café 你好".to_string();
app.cursor_position = 5; assert!(app.kill_to_end_of_line());
assert_eq!(app.input, "café ");
assert_eq!(app.cursor_position, 5);
assert_eq!(app.kill_buffer, "你好");
assert!(app.yank());
assert_eq!(app.input, "café 你好");
assert_eq!(app.cursor_position, 7);
}
#[test]
fn selection_range_returns_none_when_no_anchor() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "hello world".to_string();
app.cursor_position = 5;
app.selection_anchor = None;
assert!(app.selection_range().is_none());
}
#[test]
fn selection_range_returns_ordered_range() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "hello world".to_string();
app.cursor_position = 5;
app.selection_anchor = Some(2);
assert_eq!(app.selection_range(), Some((2, 5)));
}
#[test]
fn selection_range_normalizes_order() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "hello world".to_string();
app.cursor_position = 2;
app.selection_anchor = Some(5);
assert_eq!(app.selection_range(), Some((2, 5)));
}
#[test]
fn selection_range_returns_none_when_anchor_equals_cursor() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "hello".to_string();
app.cursor_position = 3;
app.selection_anchor = Some(3);
assert!(app.selection_range().is_none());
}
#[test]
fn delete_selection_removes_selected_text() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "hello world".to_string();
app.cursor_position = 5;
app.selection_anchor = Some(2);
assert!(app.delete_selection());
assert_eq!(app.input, "he world");
assert_eq!(app.cursor_position, 2);
assert!(app.selection_anchor.is_none());
}
#[test]
fn insert_char_replaces_selection() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "hello world".to_string();
app.cursor_position = 5;
app.selection_anchor = Some(2);
app.insert_char('X');
assert_eq!(app.input, "heX world");
assert_eq!(app.cursor_position, 3);
assert!(app.selection_anchor.is_none());
}
#[test]
fn delete_char_removes_selection_instead_of_single_char() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "hello world".to_string();
app.cursor_position = 5;
app.selection_anchor = Some(2);
app.delete_char();
assert_eq!(app.input, "he world");
assert_eq!(app.cursor_position, 2);
}
#[test]
fn selected_text_returns_correct_substring() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "hello world".to_string();
app.cursor_position = 5;
app.selection_anchor = Some(2);
assert_eq!(app.selected_text(), "llo");
}
#[test]
fn insert_str_replaces_selection() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "hello world".to_string();
app.cursor_position = 5;
app.selection_anchor = Some(2);
app.insert_str("yo");
assert_eq!(app.input, "heyo world");
assert_eq!(app.cursor_position, 4);
assert!(app.selection_anchor.is_none());
}
#[test]
fn delete_selection_noop_when_no_selection() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "hello".to_string();
app.cursor_position = 3;
app.selection_anchor = None;
assert!(!app.delete_selection());
assert_eq!(app.input, "hello");
assert_eq!(app.cursor_position, 3);
}
#[test]
fn grapheme_boundaries_snap_around_zwj_emoji_and_flags() {
let text = "a👩👩👧👦b";
let family_chars = "👩👩👧👦".chars().count();
assert_eq!(family_chars, 7);
assert_eq!(next_grapheme_boundary(text, 1), 1 + family_chars);
assert_eq!(prev_grapheme_boundary(text, 1 + family_chars), 1);
assert_eq!(prev_grapheme_boundary(text, 3), 1);
assert_eq!(next_grapheme_boundary(text, 3), 1 + family_chars);
let flag = "🇯🇵";
assert_eq!(flag.chars().count(), 2);
assert_eq!(next_grapheme_boundary(flag, 0), 2);
assert_eq!(prev_grapheme_boundary(flag, 2), 0);
}
#[test]
fn cursor_moves_by_grapheme_over_emoji_and_cjk() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "你👍🏽好".to_string(); app.cursor_position = 0;
app.move_cursor_right();
assert_eq!(app.cursor_position, 1); app.move_cursor_right();
assert_eq!(app.cursor_position, 3); app.move_cursor_right();
assert_eq!(app.cursor_position, 4); app.move_cursor_right();
assert_eq!(app.cursor_position, 4); app.move_cursor_left();
assert_eq!(app.cursor_position, 3);
app.move_cursor_left();
assert_eq!(app.cursor_position, 1);
app.move_cursor_left();
assert_eq!(app.cursor_position, 0);
app.move_cursor_left();
assert_eq!(app.cursor_position, 0); }
#[test]
fn backspace_removes_whole_emoji_cluster() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "hi👩👩👧👦".to_string();
app.cursor_position = char_count(&app.input);
app.delete_char();
assert_eq!(app.input, "hi");
assert_eq!(app.cursor_position, 2);
}
#[test]
fn forward_delete_removes_whole_flag_cluster() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "🇯🇵ok".to_string();
app.cursor_position = 0;
app.delete_char_forward();
assert_eq!(app.input, "ok");
assert_eq!(app.cursor_position, 0);
}
#[test]
fn backspace_deletes_cjk_per_character() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "你好".to_string();
app.cursor_position = 2;
app.delete_char();
assert_eq!(app.input, "你");
app.delete_char();
assert_eq!(app.input, "");
}
#[test]
fn vim_x_removes_whole_grapheme_cluster() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "👍🏽a".to_string();
app.cursor_position = 0;
app.vim_delete_char_under_cursor();
assert_eq!(app.input, "a");
assert_eq!(app.cursor_position, 0);
}
#[test]
fn select_all_covers_whole_draft() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "hello 你好 🇯🇵".to_string();
app.cursor_position = 3;
app.select_all();
assert_eq!(app.selection_anchor, Some(0));
assert_eq!(app.cursor_position, char_count(&app.input));
assert_eq!(app.selected_text(), "hello 你好 🇯🇵");
}
#[test]
fn select_all_on_empty_composer_sets_no_anchor() {
let mut app = App::new(test_options(false), &Config::default());
app.select_all();
assert!(app.selection_anchor.is_none());
assert!(app.selection_range().is_none());
}
#[test]
fn select_all_then_typing_replaces_everything_recoverably() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "precious draft".to_string();
app.select_all();
app.insert_char('x');
assert_eq!(app.input, "x");
assert_eq!(app.cursor_position, 1);
assert_eq!(app.clear_undo_buffer.as_deref(), Some("precious draft"));
assert!(app.draft_history.iter().any(|d| d == "precious draft"));
}
#[test]
fn select_all_then_backspace_is_recoverable_with_ctrl_z() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "do not lose me".to_string();
app.select_all();
app.delete_char();
assert_eq!(app.input, "");
assert!(app.restore_last_cleared_input_if_empty());
assert_eq!(app.input, "do not lose me");
assert_eq!(app.cursor_position, char_count(&app.input));
}
#[test]
fn partial_selection_delete_does_not_stash_undo_buffer() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "hello world".to_string();
app.selection_anchor = Some(0);
app.cursor_position = 5;
assert!(app.delete_selection());
assert_eq!(app.input, " world");
assert!(app.clear_undo_buffer.is_none());
}
#[test]
fn delete_selection_handles_cjk_and_emoji_ranges() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "a你👩👩👧👦好b".to_string();
app.selection_anchor = Some(1);
app.cursor_position = 10;
assert_eq!(app.selected_text(), "你👩👩👧👦好");
assert!(app.delete_selection());
assert_eq!(app.input, "ab");
assert_eq!(app.cursor_position, 1);
}
#[test]
fn shift_home_end_style_selection_uses_line_bounds() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "first line\nsecond line".to_string();
app.cursor_position = 11 + 7;
app.selection_anchor = Some(app.cursor_position);
app.move_cursor_line_start();
assert_eq!(app.cursor_position, 11);
assert_eq!(app.selected_text(), "second ");
app.move_cursor_line_end();
assert_eq!(app.cursor_position, char_count(&app.input));
assert_eq!(app.selected_text(), "line");
}
#[test]
fn word_selection_extends_by_word_and_replaces_on_type() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "alpha beta gamma".to_string();
app.cursor_position = 0;
app.selection_anchor = Some(app.cursor_position);
app.move_cursor_word_forward();
app.move_cursor_word_forward();
assert_eq!(app.selected_text(), "alpha beta ");
app.insert_char('X');
assert_eq!(app.input, "Xgamma");
assert_eq!(app.cursor_position, 1);
}
fn app_with_fallback_chain(
active: ApiProvider,
fallbacks: &[codewhale_config::ProviderKind],
keyed: &[ApiProvider],
) -> App {
let settings_home = tempfile::tempdir().expect("isolated fallback settings home");
let _home = EnvVarGuard::set("HOME", settings_home.path());
let _user_profile = EnvVarGuard::set("USERPROFILE", settings_home.path());
let _codewhale_home =
EnvVarGuard::set("CODEWHALE_HOME", settings_home.path().join(".codewhale"));
let _deepseek_config = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
let _codewhale_config = EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
let mut providers = ProvidersConfig::default();
for provider in keyed {
let entry = ProviderConfig {
api_key: Some(format!("test-key-{}", provider.as_str())),
..Default::default()
};
match provider {
ApiProvider::Deepseek => providers.deepseek = entry,
ApiProvider::Openai => providers.openai = entry,
ApiProvider::Openrouter => providers.openrouter = entry,
ApiProvider::Together => providers.together = entry,
ApiProvider::Fireworks => providers.fireworks = entry,
other => panic!("unhandled keyed provider in test helper: {other:?}"),
}
}
let config = Config {
provider: Some(active.as_str().to_string()),
fallback_providers: fallbacks.to_vec(),
providers: Some(providers),
..Default::default()
};
let mut options = test_options(false);
options.start_in_agent_mode = true;
options.skip_onboarding = true;
App::new(options, &config)
}
#[test]
fn advance_fallback_skips_unauthed_middle_provider_and_lands_on_next_ready() {
let _lock = lock_test_env();
let _openai = EnvVarGuard::remove("OPENAI_API_KEY");
let _openrouter = EnvVarGuard::remove("OPENROUTER_API_KEY");
let _together = EnvVarGuard::remove("TOGETHER_API_KEY");
let mut app = app_with_fallback_chain(
ApiProvider::Openai,
&[
codewhale_config::ProviderKind::Openrouter,
codewhale_config::ProviderKind::Together,
],
&[ApiProvider::Openai, ApiProvider::Together],
);
assert_eq!(app.fallback_chain_position(), Some(0));
let next = app.advance_fallback("network error");
assert_eq!(next, Some(ApiProvider::Together));
assert_eq!(app.api_provider, ApiProvider::Together);
assert_eq!(app.fallback_chain_position(), Some(2));
let reason = app.last_fallback_reason.as_deref().unwrap_or_default();
assert!(
reason.contains("Fell back to together"),
"reason should name the landed provider: {reason}"
);
assert!(
reason.contains("skipped openrouter: needs auth"),
"reason should note the skipped provider: {reason}"
);
}
#[test]
fn advance_fallback_local_provider_is_eligible_without_a_key() {
let _lock = lock_test_env();
let _openai = EnvVarGuard::remove("OPENAI_API_KEY");
let mut app = app_with_fallback_chain(
ApiProvider::Openai,
&[codewhale_config::ProviderKind::Ollama],
&[ApiProvider::Openai],
);
let next = app.advance_fallback("timeout");
assert_eq!(
next,
Some(ApiProvider::Ollama),
"self-hosted providers are ready without a key"
);
assert_eq!(app.api_provider, ApiProvider::Ollama);
let reason = app.last_fallback_reason.as_deref().unwrap_or_default();
assert!(reason.contains("Fell back to ollama"), "{reason}");
assert!(
!reason.contains("skipped"),
"no providers should be skipped: {reason}"
);
}
#[test]
fn advance_fallback_all_unready_exhausts_with_clear_reason() {
let _lock = lock_test_env();
let _openai = EnvVarGuard::remove("OPENAI_API_KEY");
let _openrouter = EnvVarGuard::remove("OPENROUTER_API_KEY");
let _together = EnvVarGuard::remove("TOGETHER_API_KEY");
let mut app = app_with_fallback_chain(
ApiProvider::Openai,
&[
codewhale_config::ProviderKind::Openrouter,
codewhale_config::ProviderKind::Together,
],
&[ApiProvider::Openai],
);
let next = app.advance_fallback("rate limited");
assert_eq!(next, None, "no ready fallback remains");
assert_eq!(app.api_provider, ApiProvider::Openai);
let reason = app.last_fallback_reason.as_deref().unwrap_or_default();
assert!(
reason.contains("Fallback chain exhausted"),
"reason should state exhaustion: {reason}"
);
assert!(
reason.contains("skipped openrouter: needs auth")
&& reason.contains("skipped together: needs auth"),
"reason should note every skipped provider: {reason}"
);
}
#[test]
fn startup_and_fallback_skip_inactive_external_only_routes_without_io() {
let _lock = lock_test_env();
let temp = tempfile::tempdir().expect("external fallback fixtures");
let codex_path = temp.path().join("codex-auth.json");
let grok_path = temp.path().join("grok-auth.json");
let codex_raw = "inactive Codex bytes must not be read";
let grok_raw = "inactive Grok bytes must not be read";
std::fs::write(&codex_path, codex_raw).expect("write Codex trap");
std::fs::write(&grok_path, grok_raw).expect("write Grok trap");
let _home = EnvVarGuard::set("CODEWHALE_HOME", temp.path().join("owned-home"));
let _codex_path = EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &codex_path);
let _grok_path = EnvVarGuard::set("GROK_AUTH_PATH", &grok_path);
let _codex_access = EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
let _legacy_codex_access = EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
let _xai_key = EnvVarGuard::remove("XAI_API_KEY");
let _cli_key = EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
let _cli_source = EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
let config = Config {
provider: Some(ApiProvider::Deepseek.as_str().to_string()),
api_key: Some("active-deepseek-key".to_string()),
fallback_providers: vec![
codewhale_config::ProviderKind::OpenaiCodex,
codewhale_config::ProviderKind::Xai,
],
providers: Some(ProvidersConfig {
openai_codex: ProviderConfig {
auth_mode: Some("oauth".to_string()),
external_credentials: Some(
codewhale_config::ExternalCredentialConsentToml::read_only(
codewhale_config::ProviderKind::OpenaiCodex,
codewhale_config::ExternalCredentialSource::CodexCli,
codex_path.clone(),
),
),
..Default::default()
},
xai: ProviderConfig {
auth_mode: Some("oauth".to_string()),
external_credentials: Some(
codewhale_config::ExternalCredentialConsentToml::read_only(
codewhale_config::ProviderKind::Xai,
codewhale_config::ExternalCredentialSource::GrokCli,
grok_path.clone(),
),
),
..Default::default()
},
..Default::default()
}),
..Default::default()
};
let mut options = test_options(false);
options.skip_onboarding = true;
crate::external_credentials::reset_side_effect_trap();
let mut app = App::new(options, &config);
assert_eq!(
crate::external_credentials::side_effect_trap_counts(),
(0, 0),
"startup readiness must not inspect inactive external credentials"
);
assert_eq!(app.advance_fallback("active route unavailable"), None);
assert_eq!(
crate::external_credentials::side_effect_trap_counts(),
(0, 0),
"fallback selection must skip external-only inactive routes without inspection"
);
let reason = app.last_fallback_reason.as_deref().unwrap_or_default();
assert!(
reason.contains("skipped openai-codex: needs auth"),
"{reason}"
);
assert!(reason.contains("skipped xai: needs auth"), "{reason}");
assert_eq!(
std::fs::read_to_string(&codex_path).expect("Codex trap unchanged"),
codex_raw
);
assert_eq!(
std::fs::read_to_string(&grok_path).expect("Grok trap unchanged"),
grok_raw
);
}
#[test]
fn advance_fallback_local_primary_does_not_fall_back_to_cloud() {
let _lock = lock_test_env();
let _openai = EnvVarGuard::remove("OPENAI_API_KEY");
let _deepseek = EnvVarGuard::remove("DEEPSEEK_API_KEY");
let mut app = app_with_fallback_chain(
ApiProvider::Ollama,
&[codewhale_config::ProviderKind::Deepseek],
&[ApiProvider::Deepseek],
);
let next = app.advance_fallback("local runtime unavailable");
assert_eq!(next, None, "local->cloud fallback must be blocked");
assert_eq!(app.api_provider, ApiProvider::Ollama);
let reason = app.last_fallback_reason.as_deref().unwrap_or_default();
assert!(
reason.contains("local/private policy"),
"block reason must be visible and specific: {reason}"
);
assert!(
!reason.contains("needs auth"),
"the block is policy, not missing auth: {reason}"
);
}
#[test]
fn advance_fallback_local_primary_may_fall_back_to_local_sibling() {
let _lock = lock_test_env();
let mut app = app_with_fallback_chain(
ApiProvider::Ollama,
&[codewhale_config::ProviderKind::Vllm],
&[],
);
let next = app.advance_fallback("local runtime unavailable");
assert_eq!(
next,
Some(ApiProvider::Vllm),
"local->local fallback stays within the private posture"
);
assert_eq!(app.api_provider, ApiProvider::Vllm);
let reason = app.last_fallback_reason.as_deref().unwrap_or_default();
assert!(reason.contains("Fell back to vllm"), "{reason}");
}
#[test]
fn advance_fallback_cloud_primary_can_hop_cloud_to_local_to_cloud() {
let _lock = lock_test_env();
let _openai = EnvVarGuard::remove("OPENAI_API_KEY");
let _deepseek = EnvVarGuard::remove("DEEPSEEK_API_KEY");
let mut app = app_with_fallback_chain(
ApiProvider::Openai,
&[
codewhale_config::ProviderKind::Ollama,
codewhale_config::ProviderKind::Deepseek,
],
&[ApiProvider::Openai, ApiProvider::Deepseek],
);
let local = app.advance_fallback("cloud provider timed out");
assert_eq!(local, Some(ApiProvider::Ollama));
assert_eq!(app.api_provider, ApiProvider::Ollama);
let cloud = app.advance_fallback("local runtime unavailable");
assert_eq!(cloud, Some(ApiProvider::Deepseek));
assert_eq!(app.api_provider, ApiProvider::Deepseek);
let reason = app.last_fallback_reason.as_deref().unwrap_or_default();
assert!(reason.contains("Fell back to deepseek"), "{reason}");
assert!(
!reason.contains("local/private policy"),
"cloud-primary chains should not trigger local/private blocking: {reason}"
);
}
#[test]
fn status_classifier_does_not_paint_negated_success_green() {
use super::StatusToastLevel;
let (level, _, _) = App::classify_status_text("Custom provider was not saved.");
assert_ne!(level, StatusToastLevel::Success);
let (level, _, _) = App::classify_status_text("Queued message not found");
assert_ne!(level, StatusToastLevel::Success);
let (level, _, _) = App::classify_status_text("Could not enable subagents");
assert_ne!(level, StatusToastLevel::Success);
let (level, _, _) = App::classify_status_text("No sessions found");
assert_ne!(level, StatusToastLevel::Success);
let (level, _, _) = App::classify_status_text("Fleet profile saved: reviewer.toml");
assert_eq!(level, StatusToastLevel::Success);
let (level, _, _) = App::classify_status_text("Turn canceled");
assert_eq!(level, StatusToastLevel::Warning);
let (level, _, _) = App::classify_status_text("Turn cancelled");
assert_eq!(level, StatusToastLevel::Warning);
}
#[test]
fn onboarding_provider_copy_is_provider_neutral_in_en() {
use crate::localization::{Locale, MessageId, tr};
let title = tr(Locale::En, MessageId::OnboardProviderTitle);
let blurb = tr(Locale::En, MessageId::OnboardProviderBlurb);
let api_title = tr(Locale::En, MessageId::OnboardApiKeyTitle);
assert!(!title.to_ascii_lowercase().contains("deepseek"), "{title}");
assert!(!blurb.to_ascii_lowercase().contains("deepseek"), "{blurb}");
assert!(
!api_title.to_ascii_lowercase().contains("deepseek"),
"{api_title}"
);
}
#[test]
fn agent_current_activity_bounds_redacts_and_strips_control_sequences() {
let secret = "sk-activity-secret-1234567890";
let raw = format!(
"\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}"
);
let activity = AgentCurrentActivity::bounded(
AgentCurrentActivityStatus::Running,
Some(raw.clone()),
Some(format!("\u{1b}[33mFile.read\u{1b}[0m {secret}")),
Some(4),
);
let detail = activity.detail.expect("bounded detail");
let tool = activity.current_tool.expect("bounded tool");
assert!(detail.contains("running"), "{detail:?}");
assert!(detail.contains("api_key=[redacted]"), "{detail:?}");
assert!(detail.contains("details"), "{detail:?}");
assert!(tool.contains("File.read"), "{tool:?}");
assert!(tool.contains("[redacted]"), "{tool:?}");
for safe in [&detail, &tool] {
assert!(!safe.contains(secret), "{safe:?}");
assert!(!safe.contains('\u{1b}'), "{safe:?}");
assert!(!safe.contains('\u{1}'), "{safe:?}");
assert!(!safe.contains("example.invalid"), "{safe:?}");
}
assert_eq!(activity.step, Some(4));
assert_eq!(
raw.matches(secret).count(),
1,
"source text stays untouched"
);
}
fn sealed_settings_home(tmp: &std::path::Path) -> Vec<EnvVarGuard> {
vec![
EnvVarGuard::set("HOME", tmp),
EnvVarGuard::set("USERPROFILE", tmp),
EnvVarGuard::set("CODEWHALE_HOME", tmp.join(".codewhale")),
EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH"),
EnvVarGuard::remove("CODEWHALE_CONFIG_PATH"),
]
}
#[test]
fn interactive_mode_cycle_persists_the_startup_default() {
let _lock = lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let _env = sealed_settings_home(tmp.path());
let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
let mut app = App::new(test_options(false), &Config::default());
app.mode = AppMode::Agent;
app.cycle_mode();
assert_eq!(
app.mode,
AppMode::Operate,
"Act -> Operate is the Tab cycle"
);
let reloaded = Settings::load().expect("reload settings");
assert_eq!(
reloaded.default_mode, "operate",
"the mode the user cycled into must be the startup default"
);
assert_eq!(
AppMode::from_setting(&reloaded.default_mode),
AppMode::Operate,
"a restart must restore the last user choice"
);
assert!(
app.startup_defaults.drain_failures().is_empty(),
"a successful write must not report a failure"
);
}
#[test]
fn explicit_mode_selection_and_hotbar_share_the_persistence_owner() {
let _lock = lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let _env = sealed_settings_home(tmp.path());
let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
let mut app = App::new(test_options(false), &Config::default());
assert_eq!(app.select_mode(AppMode::Plan), SettingSelection::Changed);
assert_eq!(Settings::load().expect("reload").default_mode, "plan");
assert_eq!(app.select_mode(AppMode::Yolo), SettingSelection::Changed);
assert_eq!(Settings::load().expect("reload").default_mode, "agent");
}
#[test]
fn session_restore_and_effective_turn_paths_do_not_rewrite_the_startup_default() {
let _lock = lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let _env = sealed_settings_home(tmp.path());
let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
let mut app = App::new(test_options(false), &Config::default());
app.select_mode(AppMode::Plan);
assert_eq!(Settings::load().expect("reload").default_mode, "plan");
assert!(app.set_mode(AppMode::Operate));
assert_eq!(app.mode, AppMode::Operate);
assert_eq!(
Settings::load().expect("reload").default_mode,
"plan",
"restoring a session must not rewrite the startup default"
);
}
#[test]
fn reselecting_restored_live_mode_updates_the_startup_default() {
let _lock = lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let _env = sealed_settings_home(tmp.path());
let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
Settings::transact(|settings| {
settings.default_mode = "agent".to_string();
Ok(())
})
.expect("seed startup default");
let mut app = App::new(test_options(false), &Config::default());
assert!(app.set_mode(AppMode::Operate), "simulate session restore");
assert_eq!(Settings::load().expect("reload").default_mode, "agent");
assert_eq!(
app.select_mode(AppMode::Operate),
SettingSelection::PersistedSame,
"an accepted selection that did not move live mode is not a refusal"
);
assert_eq!(
Settings::load().expect("reload").default_mode,
"operate",
"the explicit same-live selection must still become the startup default"
);
}
#[test]
fn mode_change_refused_while_a_turn_runs_persists_nothing() {
let _lock = lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let _env = sealed_settings_home(tmp.path());
let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
let mut app = App::new(test_options(false), &Config::default());
app.select_mode(AppMode::Plan);
app.is_loading = true;
app.cycle_mode();
assert_eq!(app.mode, AppMode::Plan, "#2982 lock still holds");
assert_eq!(
Settings::load().expect("reload").default_mode,
"plan",
"a refused change must not be persisted"
);
}
#[test]
fn reasoning_cycle_persists_through_the_same_owner_as_the_picker() {
let _lock = lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let _env = sealed_settings_home(tmp.path());
let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
let mut app = App::new(test_options(false), &Config::default());
app.api_provider = ApiProvider::Deepseek;
app.auto_model = false;
app.reasoning_effort = ReasoningEffort::Off;
app.apply_reasoning_effort_cycle();
assert_eq!(app.reasoning_effort, ReasoningEffort::Low);
assert_eq!(
Settings::load()
.expect("reload settings")
.reasoning_effort
.as_deref(),
Some("low"),
"a restart must restore the last thinking choice"
);
}
#[test]
fn failed_startup_default_write_is_reported_not_swallowed() {
let _lock = lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let blocked_home = tmp.path().join("codewhale-home-file");
std::fs::write(&blocked_home, "not a directory").expect("blocking file");
let _home = EnvVarGuard::set("HOME", tmp.path());
let _user_profile = EnvVarGuard::set("USERPROFILE", tmp.path());
let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &blocked_home);
let _deepseek_config = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
let _codewhale_config = EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
let mut app = App::new(test_options(false), &Config::default());
assert_eq!(
app.select_mode(AppMode::Plan),
SettingSelection::Changed,
"the live session still changes; only the durable write fails"
);
assert_eq!(app.mode, AppMode::Plan);
app.drain_startup_default_failures();
let toast = app
.status_toasts
.iter()
.find(|toast| toast.text.contains("startup mode"))
.expect("a failed startup-default write must surface a toast");
assert!(
toast.text.contains("was not saved"),
"toast must say the write did not land, got {:?}",
toast.text
);
assert!(
!toast.text.contains(".codewhale"),
"a failure toast must not carry the settings path, got {:?}",
toast.text
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn rapid_mode_selections_persist_the_last_one_not_the_last_to_finish() {
let _lock = lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let _env = sealed_settings_home(tmp.path());
let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
let mut app = App::new(test_options(false), &Config::default());
for mode in [
AppMode::Plan,
AppMode::Operate,
AppMode::Agent,
AppMode::Plan,
AppMode::Operate,
AppMode::Agent,
AppMode::Plan,
] {
app.select_mode(mode);
}
app.startup_defaults.flush();
assert_eq!(app.mode, AppMode::Plan);
assert_eq!(
Settings::load().expect("reload").default_mode,
"plan",
"the last selection must win, whatever order the writers ran in"
);
assert!(
app.startup_defaults.drain_failures().is_empty(),
"no write in the burst may fail"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn rapid_thinking_selections_persist_the_last_one() {
let _lock = lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let _env = sealed_settings_home(tmp.path());
let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
let mut app = App::new(test_options(false), &Config::default());
app.api_provider = ApiProvider::Deepseek;
app.auto_model = false;
app.reasoning_effort = ReasoningEffort::Off;
for _ in 0..6 {
app.apply_reasoning_effort_cycle();
}
app.startup_defaults.flush();
let expected = app.reasoning_effort.as_setting_for_route(
app.api_provider,
&app.active_route_base_url,
&app.model,
);
assert_eq!(
Settings::load()
.expect("reload")
.reasoning_effort
.as_deref(),
Some(expected),
"the tier the session ended on must be the tier on disk"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn fixed_route_thinking_cycle_persists_raw_preference() {
let _lock = lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let _env = sealed_settings_home(tmp.path());
let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
let mut app = App::new(test_options(false), &Config::default());
app.api_provider = ApiProvider::Moonshot;
app.auto_model = false;
app.active_route_base_url = crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string();
app.model = crate::config::MOONSHOT_KIMI_K3_MODEL.to_string();
app.reasoning_effort = ReasoningEffort::Max;
app.apply_reasoning_effort_cycle();
app.startup_defaults.flush();
assert_eq!(app.reasoning_effort, ReasoningEffort::Auto);
assert_eq!(app.reasoning_effort_preference, Some(ReasoningEffort::Auto));
assert_eq!(
Settings::load()
.expect("reload")
.reasoning_effort
.as_deref(),
Some("auto"),
"the raw preference the cycle landed on must survive restart"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn interleaved_mode_thinking_and_model_writes_do_not_clobber_each_other() {
let _lock = lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let _env = sealed_settings_home(tmp.path());
let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
let mut app = App::new(test_options(false), &Config::default());
app.api_provider = ApiProvider::Deepseek;
app.auto_model = false;
app.reasoning_effort = ReasoningEffort::Off;
assert_eq!(app.select_mode(AppMode::Plan), SettingSelection::Changed);
app.apply_reasoning_effort_cycle();
let cycled_effort = app.reasoning_effort.as_setting_for_route(
app.api_provider,
&app.active_route_base_url,
&app.model,
);
app.startup_defaults
.apply_blocking(
crate::tui::startup_defaults::StartupDefaults::default()
.with_default_model("deepseek-chat"),
)
.expect("model write must land");
let after_model = Settings::load().expect("reload");
let persisted_model = after_model
.default_model
.clone()
.expect("model picker write must be on disk");
assert_eq!(
after_model.default_mode, "plan",
"the queued mode selection must have been applied before the model write"
);
assert_eq!(
after_model.reasoning_effort.as_deref(),
Some(cycled_effort),
"the queued thinking selection must not be lost by the model write"
);
assert_eq!(app.select_mode(AppMode::Operate), SettingSelection::Changed);
app.startup_defaults.flush();
let final_settings = Settings::load().expect("reload");
assert_eq!(final_settings.default_mode, "operate");
assert_eq!(
final_settings.default_model.as_deref(),
Some(persisted_model.as_str()),
"a mode write must not roll back the model"
);
assert_eq!(
final_settings.reasoning_effort.as_deref(),
Some(cycled_effort),
"a mode write must not roll back the thinking level"
);
assert!(app.startup_defaults.drain_failures().is_empty());
}
fn sealed_settings_with_root_config(
tmp: &std::path::Path,
) -> (std::path::PathBuf, Vec<EnvVarGuard>) {
let config_path = tmp.join("config.toml");
let guards = vec![
EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path),
EnvVarGuard::remove("CODEWHALE_CONFIG_PATH"),
EnvVarGuard::remove("DEEPSEEK_APPROVAL_POLICY"),
];
(config_path, guards)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn mode_and_permission_posture_writes_do_not_clobber_each_other() {
let _lock = lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let (config_path, _env) = sealed_settings_with_root_config(tmp.path());
let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
let mut options = test_options(false);
options.start_in_agent_mode = true;
options.config_path = Some(config_path);
let mut app = App::new(options, &Config::default());
app.approval_mode = ApprovalMode::Suggest;
app.mode = AppMode::Agent;
for next_mode in [
AppMode::Operate,
AppMode::Agent,
AppMode::Operate,
AppMode::Agent,
AppMode::Operate,
] {
assert_eq!(
app.select_mode(next_mode),
SettingSelection::Changed,
"mode selection must change mode"
);
assert!(
app.cycle_approval_posture(),
"the posture write must succeed, or the assertion below is vacuous"
);
}
app.startup_defaults.flush();
let expected_posture = App::approval_posture_setting(app.mode_prefs.agent_approval_mode);
let saved = Settings::load_persisted().expect("reload settings");
assert_eq!(
saved.default_mode, "operate",
"the posture writer must not revert the mode the user cycled into"
);
assert_eq!(
saved.permission_posture.as_deref(),
Some(expected_posture),
"the mode writer must not revert the posture the user cycled into"
);
assert!(
app.startup_defaults.drain_failures().is_empty(),
"no write in the burst may fail"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn thinking_and_an_unrelated_direct_setting_write_do_not_clobber_each_other() {
let _lock = lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let _env = sealed_settings_home(tmp.path());
let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
let mut app = App::new(test_options(false), &Config::default());
app.api_provider = ApiProvider::Deepseek;
app.auto_model = false;
app.reasoning_effort = ReasoningEffort::Off;
for index in 0..6 {
app.apply_reasoning_effort_cycle();
Settings::transact(|settings| settings.set("max_history", &(100 + index).to_string()))
.expect("the direct write must land");
}
app.startup_defaults.flush();
let expected_effort = app.reasoning_effort.as_setting_for_route(
app.api_provider,
&app.active_route_base_url,
&app.model,
);
let saved = Settings::load_persisted().expect("reload settings");
assert_eq!(
saved.reasoning_effort.as_deref(),
Some(expected_effort),
"the direct writer must not revert the thinking level"
);
assert_eq!(
saved.max_input_history, 105,
"the thinking writer must not revert the last direct write"
);
assert!(app.startup_defaults.drain_failures().is_empty());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn rapid_mixed_writes_settle_on_the_last_value_for_every_field() {
let _lock = lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let (config_path, _env) = sealed_settings_with_root_config(tmp.path());
let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
let mut options = test_options(false);
options.start_in_agent_mode = true;
options.config_path = Some(config_path);
let mut app = App::new(options, &Config::default());
app.api_provider = ApiProvider::Deepseek;
app.auto_model = false;
app.reasoning_effort = ReasoningEffort::Off;
app.approval_mode = ApprovalMode::Suggest;
app.mode = AppMode::Agent;
for index in 0..5 {
assert_eq!(
app.select_mode(if index % 2 == 0 {
AppMode::Operate
} else {
AppMode::Agent
}),
SettingSelection::Changed
);
app.apply_reasoning_effort_cycle();
assert!(app.cycle_approval_posture());
Settings::transact(|settings| settings.set("max_history", &(200 + index).to_string()))
.expect("the direct write must land");
}
app.startup_defaults
.apply_blocking(
crate::tui::startup_defaults::StartupDefaults::default()
.with_default_model("deepseek-chat"),
)
.expect("model write must land");
app.startup_defaults.flush();
let expected_effort = app.reasoning_effort.as_setting_for_route(
app.api_provider,
&app.active_route_base_url,
&app.model,
);
let expected_posture = App::approval_posture_setting(app.mode_prefs.agent_approval_mode);
let saved = Settings::load_persisted().expect("reload settings");
assert_eq!(saved.default_mode, app.mode.as_setting());
assert_eq!(saved.reasoning_effort.as_deref(), Some(expected_effort));
assert_eq!(saved.permission_posture.as_deref(), Some(expected_posture));
assert_eq!(saved.max_input_history, 204);
assert_eq!(saved.default_model.as_deref(), Some("deepseek-chat"));
assert!(app.startup_defaults.drain_failures().is_empty());
}
#[test]
fn mode_cycling_in_an_unsealed_test_writes_nothing() {
let mut app = App::new(test_options(false), &Config::default());
app.mode = AppMode::Agent;
assert_eq!(
app.select_mode(AppMode::Operate),
SettingSelection::Changed,
"the live session must still change"
);
assert_eq!(app.mode, AppMode::Operate);
assert_eq!(
app.startup_defaults.pending_len(),
0,
"an unsealed test must enqueue nothing a later sealed drain could inherit"
);
assert!(
app.startup_defaults.drain_failures().is_empty(),
"a skipped test write is not a user-visible failure"
);
}
#[test]
fn slash_config_and_set_refuse_every_live_route_key_while_a_turn_runs() {
let _lock = lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let _env = sealed_settings_home(tmp.path());
let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
Settings::transact(|settings| {
settings.default_mode = "plan".to_string();
settings.default_model = Some("deepseek-chat".to_string());
settings.reasoning_effort = Some("off".to_string());
Ok(())
})
.expect("seed the persisted route");
let before = Settings::load_persisted().expect("read the seeded settings");
let mut app = App::new(test_options(false), &Config::default());
app.api_provider = ApiProvider::Deepseek;
app.auto_model = false;
app.set_model_selection("deepseek-chat".to_string());
app.reasoning_effort = ReasoningEffort::Off;
let _ = app.set_mode(AppMode::Plan);
app.is_loading = true;
let live_mode = app.mode;
let live_model = app.model.clone();
let live_effort = app.reasoning_effort;
let live_provider = app.api_provider;
for persist in [true, false] {
for (key, value) in [
("model", "deepseek-v4-pro"),
("default_model", "deepseek-v4-pro"),
("reasoning_effort", "high"),
("effort", "high"),
("mode", "operate"),
("provider", "openai"),
] {
let result = crate::commands::set_config_value(&mut app, key, value, persist);
assert!(
result.is_error,
"/set {key} {value} (persist={persist}) must be refused mid-turn"
);
let message = result.message.unwrap_or_default();
assert!(
message.contains("locked while a turn is running"),
"the refusal must say why, got {message:?}"
);
}
}
assert_eq!(app.mode, live_mode, "live mode must not move");
assert_eq!(app.model, live_model, "the live route model must not move");
assert_eq!(
app.reasoning_effort, live_effort,
"the live thinking tier must not move"
);
assert_eq!(
app.api_provider, live_provider,
"the live provider must not move"
);
let after = Settings::load_persisted().expect("reload settings");
assert_eq!(after.default_mode, before.default_mode);
assert_eq!(after.default_model, before.default_model);
assert_eq!(after.reasoning_effort, before.reasoning_effort);
assert_eq!(after.provider_models, before.provider_models);
assert_eq!(
app.startup_defaults.pending_len(),
0,
"a refused command must not queue a startup-default write"
);
app.startup_defaults.flush();
assert!(
app.startup_defaults.drain_failures().is_empty(),
"a refusal is not a write failure"
);
assert_eq!(
Settings::load_persisted()
.expect("reload after flush")
.default_mode,
before.default_mode,
"nothing may land after the queue is drained either"
);
assert!(
!codewhale_config::SetupState::path()
.expect("setup state path")
.exists(),
"a refused route change must not record provider/model setup progress"
);
}
#[test]
fn restart_only_default_mode_is_still_settable_while_a_turn_runs() {
let _lock = lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let _env = sealed_settings_home(tmp.path());
let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
let mut app = App::new(test_options(false), &Config::default());
let _ = app.set_mode(AppMode::Plan);
app.is_loading = true;
let result = crate::commands::set_config_value(&mut app, "default_mode", "operate", true);
assert!(
!result.is_error,
"default_mode is restart-only, got {:?}",
result.message
);
assert_eq!(
Settings::load_persisted().expect("reload").default_mode,
"operate"
);
assert_eq!(
app.mode,
AppMode::Plan,
"a restart default must not move the live session"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn shutdown_flushes_the_last_selection_and_returns_late_failures() {
let _lock = lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let _env = sealed_settings_home(tmp.path());
let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
let mut app = App::new(test_options(false), &Config::default());
assert_eq!(app.select_mode(AppMode::Operate), SettingSelection::Changed);
let failures = app.startup_defaults.shutdown();
assert!(failures.is_empty(), "the write must land, not fail");
assert_eq!(
Settings::load_persisted().expect("reload").default_mode,
"operate",
"the last immediate selection must be on disk after shutdown"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_late_startup_default_failure_is_returned_not_only_logged() {
let _lock = lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let blocked_home = tmp.path().join("codewhale-home-file");
std::fs::write(&blocked_home, "not a directory").expect("blocking file");
let _home = EnvVarGuard::set("HOME", tmp.path());
let _user_profile = EnvVarGuard::set("USERPROFILE", tmp.path());
let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &blocked_home);
let _deepseek_config = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
let _codewhale_config = EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
let mut app = App::new(test_options(false), &Config::default());
assert_eq!(app.select_mode(AppMode::Operate), SettingSelection::Changed);
let failures = app.startup_defaults.shutdown();
let failure = failures
.first()
.expect("a failed write must be reported at shutdown, not swallowed");
assert_eq!(
failure.subjects,
vec![crate::tui::startup_defaults::StartupDefaultSubject::Mode]
);
let message = app.startup_default_failure_message(failure);
assert!(
message.contains("startup mode") && message.contains("was not saved"),
"the shutdown notice must name what was lost, got {message:?}"
);
assert!(
!message.contains(".codewhale") && !message.contains(tmp.path().to_str().unwrap()),
"the shutdown notice must not print the settings path, got {message:?}"
);
}
#[test]
fn mode_selection_reports_refusal_change_and_persisted_same_distinctly() {
let _lock = lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let _env = sealed_settings_home(tmp.path());
let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
let mut app = App::new(test_options(false), &Config::default());
let _ = app.set_mode(AppMode::Agent);
assert_eq!(app.select_mode(AppMode::Operate), SettingSelection::Changed);
assert!(SettingSelection::Changed.changed_live_state());
assert!(SettingSelection::Changed.accepted());
assert_eq!(
app.select_mode(AppMode::Operate),
SettingSelection::PersistedSame
);
assert!(
!SettingSelection::PersistedSame.changed_live_state(),
"a persisted-same selection must not resync the engine"
);
assert!(
SettingSelection::PersistedSame.accepted(),
"a persisted-same selection did write the startup default"
);
app.is_loading = true;
assert_eq!(app.select_mode(AppMode::Plan), SettingSelection::Refused);
assert!(!SettingSelection::Refused.accepted());
assert_eq!(app.mode, AppMode::Operate, "a refusal changes nothing");
}
#[test]
fn slash_mode_distinguishes_a_saved_startup_default_from_a_refusal() {
let _lock = lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let _env = sealed_settings_home(tmp.path());
let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
let mut app = App::new(test_options(false), &Config::default());
let _ = app.set_mode(AppMode::Operate);
Settings::transact(|settings| {
settings.default_mode = "agent".to_string();
Ok(())
})
.expect("seed a startup default that disagrees with the live mode");
let receipt = crate::commands::switch_mode(&mut app, AppMode::Operate);
assert!(
receipt.contains("saved as startup default"),
"the save must be reported, got {receipt:?}"
);
app.startup_defaults.flush();
assert_eq!(
Settings::load_persisted().expect("reload").default_mode,
"operate"
);
app.is_loading = true;
let refusal = crate::commands::switch_mode(&mut app, AppMode::Plan);
assert!(
refusal.contains("locked while a turn is running"),
"a refusal must not read like a save, got {refusal:?}"
);
assert_ne!(refusal, receipt);
}
#[test]
fn hotbar_mode_row_for_the_live_mode_still_shows_the_saved_receipt() {
let _lock = lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let _env = sealed_settings_home(tmp.path());
let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
let mut app = App::new(test_options(false), &Config::default());
let _ = app.set_mode(AppMode::Plan);
let outcome = app.select_mode(AppMode::Plan);
app.report_mode_selection(AppMode::Plan, outcome);
assert_eq!(outcome, SettingSelection::PersistedSame);
assert!(
app.status_message
.as_deref()
.is_some_and(|message| message.contains("saved as startup default")),
"got {:?}",
app.status_message
);
app.startup_defaults.flush();
assert_eq!(
Settings::load_persisted().expect("reload").default_mode,
"plan"
);
}
#[test]
fn an_explicit_launch_model_outranks_the_remembered_provider_model() {
let _lock = lock_test_env();
let temp = tempfile::tempdir().expect("sealed state root");
let config_path = temp.path().join("config.toml");
std::fs::write(
&config_path,
"provider = \"moonshot\"\n\n[providers.moonshot]\napi_key = \"k\"\nmodel = \"kimi-k3\"\n",
)
.expect("seed config");
std::fs::write(
temp.path().join("settings.toml"),
"[provider_models]\nmoonshot = \"kimi-k2.7-code\"\n",
)
.expect("seed settings");
let _config_path_guard = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
let _codewhale_config_path = EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
let config = Config::load(Some(config_path.clone()), None).expect("load sealed config");
let _no_flag = EnvVarGuard::remove("CODEWHALE_MODEL");
let _no_legacy_flag = EnvVarGuard::remove("DEEPSEEK_MODEL");
let remembered = App::new(
TuiOptions {
model: config.default_model(),
..test_options(false)
},
&config,
);
assert_eq!(
remembered.model, "kimi-k2.7-code",
"the remembered /model pick remains the default when nothing was named"
);
let _model_flag = EnvVarGuard::set("CODEWHALE_MODEL", "kimi-k3");
let requested = App::new(
TuiOptions {
model: config.default_model(),
..test_options(false)
},
&config,
);
assert_eq!(
requested.model, "kimi-k3",
"an explicit --model must never be silently replaced by session memory"
);
}
#[test]
fn ambient_clock_advances_by_clamped_steps() {
let mut app = App::new(test_options(false), &Config::default());
assert_eq!(app.sample_ambient_clock_ms(), 0);
app.ambient_clock_sampled_at = Some(Instant::now() - Duration::from_secs(9));
let advanced = app.sample_ambient_clock_ms();
assert!(
advanced <= App::AMBIENT_MAX_STEP_MS,
"a 9s draw gap must clamp to one step, got {advanced}ms"
);
}
#[test]
fn ambient_idle_settles_after_grace_and_wakes_on_activity() {
let mut app = App::new(test_options(false), &Config::default());
let start = Instant::now();
assert!(!app.ambient_idle_settled(false, start));
assert!(!app.ambient_idle_settled(
false,
start + Duration::from_millis(App::AMBIENT_IDLE_SETTLE_MS - 500)
));
assert!(app.ambient_idle_settled(
false,
start + Duration::from_millis(App::AMBIENT_IDLE_SETTLE_MS + 500)
));
assert!(!app.ambient_idle_settled(true, start + Duration::from_secs(60)));
assert!(!app.ambient_idle_settled(false, start + Duration::from_secs(61)));
}
#[test]
fn launch_onboarding_skips_picker_when_xai_oauth_needs_reauth() {
let (onboarding, recovery) = launch_onboarding_decision(
false, true, true, false, true, );
assert_eq!(onboarding, OnboardingState::None);
assert!(!recovery);
}
#[test]
fn launch_onboarding_opens_picker_for_generic_missing_key() {
let (onboarding, recovery) = launch_onboarding_decision(false, true, true, false, false);
assert_eq!(onboarding, OnboardingState::Provider);
assert!(recovery);
}
#[test]
fn launch_onboarding_clean_when_onboarded_with_key() {
let (onboarding, recovery) = launch_onboarding_decision(false, true, false, false, false);
assert_eq!(onboarding, OnboardingState::None);
assert!(!recovery);
}
#[test]
fn launch_onboarding_keeps_first_run_when_not_onboarded_even_if_xai_reauth() {
let (onboarding, recovery) = launch_onboarding_decision(false, false, true, false, true);
assert_eq!(onboarding, OnboardingState::Welcome);
assert!(!recovery);
}