use super::*;
use crate::output::ActivityStatus;
fn test_area() -> ratatui::layout::Rect {
ratatui::layout::Rect::new(0, 0, 100, 30)
}
#[test]
fn best_effort_sends_leave_queue_reserve_for_critical_events() {
let (sender, receiver) = bounded::<TuiEvent>(128);
for index in 0..64 {
sender
.send(TuiEvent::Output(OutputEvent::AssistantDelta {
text: format!("queued {index}"),
}))
.unwrap();
}
send_best_effort(
&sender,
TuiEvent::Output(OutputEvent::AssistantDelta {
text: "dropped preview".to_string(),
}),
);
send_critical(&sender, TuiEvent::Done).unwrap();
let events = receiver.try_iter().collect::<Vec<_>>();
assert_eq!(events.len(), 65);
assert!(matches!(events.last(), Some(TuiEvent::Done)));
assert!(!events.iter().any(|event| {
matches!(event, TuiEvent::Output(OutputEvent::AssistantDelta { text }) if text == "dropped preview")
}));
}
#[test]
fn budgeted_drain_stops_at_event_count_budget() {
let (sender, receiver) = bounded::<TuiEvent>(4);
for _ in 0..3 {
sender.send(TuiEvent::Done).unwrap();
}
let mut ui_state = state::MissionControlState::default();
let outcome = drain_tui_events_budgeted(
&receiver,
&mut ui_state,
DrainBudget {
max_events: 2,
max_elapsed: Duration::from_secs(1),
},
);
assert_eq!(outcome.processed, 2);
assert!(outcome.exhausted);
assert_eq!(receiver.len(), 1);
assert!(outcome.result.changed);
}
#[test]
fn budgeted_drain_stops_at_elapsed_budget() {
let (sender, receiver) = bounded::<TuiEvent>(4);
for _ in 0..3 {
sender.send(TuiEvent::Done).unwrap();
}
let mut ui_state = state::MissionControlState::default();
let start = Instant::now();
let mut calls = 0;
let outcome = drain_tui_events_budgeted_with_clock(
&receiver,
&mut ui_state,
DrainBudget {
max_events: 4,
max_elapsed: Duration::from_millis(2),
},
|| {
calls += 1;
if calls == 1 {
start
} else {
start + Duration::from_millis(3)
}
},
);
assert_eq!(outcome.processed, 1);
assert!(outcome.exhausted);
assert_eq!(receiver.len(), 2);
}
#[test]
fn later_budgeted_drain_resumes_and_applies_run_finished() {
let (sender, receiver) = bounded::<TuiEvent>(4);
sender.send(TuiEvent::Done).unwrap();
sender
.send(TuiEvent::RunFinished { final_event: None })
.unwrap();
let mut ui_state = state::MissionControlState::default();
let budget = DrainBudget {
max_events: 1,
max_elapsed: Duration::from_secs(1),
};
let first = drain_tui_events_budgeted(&receiver, &mut ui_state, budget);
let second = drain_tui_events_budgeted(&receiver, &mut ui_state, budget);
assert_eq!(first.processed, 1);
assert!(!first.result.run_finished);
assert_eq!(second.processed, 1);
assert!(second.result.run_finished);
assert_eq!(receiver.len(), 0);
}
#[test]
fn thinking_summary_flushes_pending_assistant_delta_before_transcript_insert() {
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut ui_state = state::MissionControlState::default();
sender
.send(TuiEvent::Output(OutputEvent::AssistantDelta {
text: "partial".to_string(),
}))
.unwrap();
sender
.send(TuiEvent::Output(OutputEvent::ThinkingSummaryComplete {
text: "provider summary".to_string(),
}))
.unwrap();
drain_tui_events(&receiver, &mut ui_state);
assert_eq!(
ui_state.transcript,
vec![
"assistant: partial".to_string(),
"thinking: provider summary".to_string(),
]
);
}
#[test]
fn model_catalog_event_opens_picker_with_captured_viewport_rows() {
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut ui_state = state::MissionControlState {
provider: crate::providers::OPENAI_CODEX_PROVIDER.to_string(),
model: "gpt-5".to_string(),
pending_model_catalog_request_id: Some(7),
..Default::default()
};
sender
.send(TuiEvent::ModelCatalog {
request_id: 7,
result: Ok(crate::model_catalog::CatalogForUi {
entries: (0..6)
.map(|index| {
crate::model_catalog::ModelCatalogEntry::new_codex(format!("gpt-{index}"))
})
.collect(),
stale: false,
notice: None,
}),
visible_rows: 2,
})
.unwrap();
let result = drain_tui_events(&receiver, &mut ui_state);
assert!(result.changed);
assert_eq!(ui_state.pending_model_catalog_request_id, None);
assert_eq!(result.model_catalog_loaded.len(), 1);
assert_eq!(result.model_catalog_loaded[0].2, 2);
assert!(ui_state.modals.model_picker.is_none());
}
#[test]
fn model_catalog_stale_cache_sets_stale_status() {
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut ui_state = state::MissionControlState {
pending_model_catalog_request_id: Some(11),
..Default::default()
};
sender
.send(TuiEvent::ModelCatalog {
request_id: 11,
result: Ok(crate::model_catalog::CatalogForUi {
entries: vec![crate::model_catalog::ModelCatalogEntry::new_codex(
"gpt-stale",
)],
stale: true,
notice: Some("using stale openai-codex model catalog".to_string()),
}),
visible_rows: 4,
})
.unwrap();
let result = drain_tui_events(&receiver, &mut ui_state);
assert!(result.changed);
assert_eq!(ui_state.pending_model_catalog_request_id, None);
assert_eq!(result.model_catalog_loaded.len(), 1);
assert!(matches!(result.model_catalog_loaded[0].1, Ok(ref catalog) if catalog.stale));
}
#[test]
fn stale_model_catalog_event_does_not_mutate_picker() {
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut ui_state = state::MissionControlState {
pending_model_catalog_request_id: Some(2),
..Default::default()
};
ui_state.open_model_picker(
vec![crate::model_catalog::ModelCatalogEntry::new_codex(
"gpt-current",
)],
std::collections::BTreeSet::new(),
Some("current notice".to_string()),
4,
);
sender
.send(TuiEvent::ModelCatalog {
request_id: 1,
result: Ok(crate::model_catalog::CatalogForUi {
entries: vec![crate::model_catalog::ModelCatalogEntry::new_codex(
"gpt-stale",
)],
stale: false,
notice: Some("stale notice".to_string()),
}),
visible_rows: 4,
})
.unwrap();
let result = drain_tui_events(&receiver, &mut ui_state);
assert!(!result.model_catalog_finished);
assert_eq!(ui_state.pending_model_catalog_request_id, Some(2));
assert_eq!(ui_state.status, "");
let picker = ui_state.modals.model_picker.as_ref().unwrap();
assert_eq!(picker.entries[0].model, "gpt-current");
assert_eq!(picker.notice.as_deref(), Some("current notice"));
}
fn test_paths(root: &std::path::Path) -> crate::config::McPaths {
crate::config::McPaths {
root: root.join("mc"),
cache: root.join("mc/cache"),
state: root.join("mc/state"),
sessions: root.join("mc/sessions"),
checkpoints: root.join("mc/checkpoints"),
skills: root.join("mc/skills"),
prompts: root.join("mc/prompts"),
subagents: root.join("mc/subagents"),
primary_agents: root.join("mc/primary-agents"),
user_agents: root.join("mc/agents"),
settings_file: root.join("mc/settings.json"),
project_settings_file: root.join("project/.magi-code/settings.json"),
local_settings_file: None,
auth_file: root.join("mc/auth.json"),
}
}
fn start_sse_server(summary: &'static str) -> String {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(2)));
let mut buffer = [0_u8; 4096];
let _ = std::io::Read::read(&mut stream, &mut buffer);
let payload = format!("data: {{\"delta\":\"{summary}\"}}\n\ndata: [DONE]\n\n");
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
payload.len(),
payload
);
let _ = std::io::Write::write_all(&mut stream, response.as_bytes());
});
format!("http://{addr}/v1")
}
fn write_compaction_override_settings(
paths: &crate::config::McPaths,
provider: &str,
model: &str,
base_url: &str,
) {
let settings = crate::config::Settings {
compaction: crate::config::CompactionSettings {
provider: Some(provider.to_string()),
model: Some(model.to_string()),
..crate::config::CompactionSettings::default()
},
custom_providers: std::collections::BTreeMap::from([(
provider.to_string(),
crate::config::make_custom_provider_config("Local", base_url, "").unwrap(),
)]),
..crate::config::Settings::default()
};
crate::config::write_settings(paths, &settings).unwrap();
}
fn test_app(temp: &tempfile::TempDir, sender: Sender<TuiEvent>) -> MissionControlApp {
test_app_with_active_session(temp, sender, None)
}
fn test_app_with_active_session(
temp: &tempfile::TempDir,
sender: Sender<TuiEvent>,
active_session: Option<Session>,
) -> MissionControlApp {
let mut app = MissionControlApp::new(
TuiSessionConfig {
config: EffectiveConfig {
provider: Some("openai".to_string()),
model: Some("test-model".to_string()),
no_color: false,
file_autocomplete_respects_gitignore: true,
custom_providers: std::collections::BTreeMap::new(),
thinking_level: crate::thinking::ThinkingLevel::Default,
api_key: None,
auth: None,
paths: test_paths(temp.path()),
},
instructions: Vec::new(),
discovered_skills: SkillDiscovery::default(),
skills: SkillDiscovery::default(),
commands: CommandRegistry::mvp(),
manager: SessionManager::new(temp.path().join("sessions")),
active_session,
cwd: temp.path().to_path_buf(),
},
sender,
);
app.startup_policy_loaded = true;
app.subagent_profile_discovery =
crate::subagents::profiles::discover_subagent_profiles(&app.config.paths.subagents);
app
}
fn write_codex_auth(paths: &crate::config::McPaths) {
let mut auth = crate::config::Auth::default();
auth.providers.insert(
crate::providers::OPENAI_CODEX_PROVIDER.to_string(),
crate::config::AuthProviderRecord::OAuth {
access: "access".into(),
refresh: Some("refresh".into()),
expires: None,
account_id: Some("acct".into()),
},
);
crate::config::write_auth(paths, &auth).unwrap();
}
fn ready_codex_app(temp: &tempfile::TempDir, sender: Sender<TuiEvent>) -> MissionControlApp {
let mut app = test_app(temp, sender);
app.config.provider = Some(crate::providers::OPENAI_CODEX_PROVIDER.to_string());
app.config.auth = Some(crate::config::ProviderCredential::OAuth {
access: "access".into(),
account_id: Some("acct".into()),
});
app.state.config = Some(app.config.clone());
app.state.auth_state = crate::config::AuthState::Ready {
provider: crate::providers::OPENAI_CODEX_PROVIDER.to_string(),
credential: crate::config::ProviderCredential::OAuth {
access: "access".into(),
account_id: Some("acct".into()),
},
};
write_codex_auth(&app.config.paths);
app
}
#[test]
fn cached_thinking_levels_for_model_uses_cache_before_name_fallback() {
let temp = tempfile::TempDir::new().unwrap();
let paths = test_paths(temp.path());
crate::config::write_settings(
&paths,
&crate::config::Settings {
custom_providers: std::collections::BTreeMap::from([(
"local-ai".to_string(),
crate::config::CustomProviderConfig {
label: "Local AI".to_string(),
base_url: "http://localhost:11434/v1".to_string(),
api_key_env_var: None,
models_dev_provider: None,
use_responses_endpoint: false,
supports_text_verbosity: false,
reasoning_protocol: crate::config::CustomReasoningProtocol::default(),
extra_models: Vec::new(),
},
)]),
..Default::default()
},
)
.unwrap();
let mut custom_supported =
crate::model_catalog::ModelCatalogEntry::new("local-ai", "plain-model");
custom_supported.reasoning_efforts =
Some(crate::thinking::ThinkingLevel::EFFORT_GENERIC.to_vec());
let mut codex_disabled = crate::model_catalog::ModelCatalogEntry::new_codex("gpt-5");
codex_disabled.reasoning_efforts = Some(vec![crate::thinking::ThinkingLevel::Default]);
crate::model_catalog::write_catalog_cache_for_configured_provider(
&paths,
"local-ai",
&[custom_supported],
)
.unwrap();
crate::model_catalog::write_catalog_cache(
&paths,
crate::providers::OPENAI_CODEX_PROVIDER,
&[codex_disabled],
)
.unwrap();
let custom_gpt_like = crate::thinking::ThinkingCapabilityScope::Custom(
crate::config::CustomReasoningProtocol::GptLike,
);
let builtin = crate::thinking::ThinkingCapabilityScope::BuiltIn;
assert_eq!(
cached_thinking_levels_for_model(&paths, "local-ai", "plain-model", custom_gpt_like),
crate::thinking::ThinkingLevel::EFFORT_GENERIC.to_vec()
);
assert_eq!(
cached_thinking_levels_for_model(
&paths,
crate::providers::OPENAI_CODEX_PROVIDER,
"gpt-5",
builtin,
),
vec![crate::thinking::ThinkingLevel::Default]
);
assert_eq!(
cached_thinking_levels_for_model(
&paths,
crate::providers::OPENAI_CODEX_PROVIDER,
"o3",
builtin,
),
crate::thinking::ThinkingLevel::EFFORT_GENERIC.to_vec()
);
assert_eq!(
cached_thinking_levels_for_model(&paths, "local-ai", "gpt-5", custom_gpt_like),
vec![crate::thinking::ThinkingLevel::Default]
);
}
#[test]
fn primary_agent_selection_persistence_writes_id_and_none() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(1);
let mut app = test_app(&temp, sender);
let mut state = state::MissionControlState::default();
app.persist_primary_agent_selection(Some("tars"), &mut state);
assert_eq!(state.status, "primary agent selected: tars");
assert_eq!(
crate::config::selected_primary_agent(&app.config.paths)
.unwrap()
.as_deref(),
Some("tars")
);
app.persist_primary_agent_selection(None, &mut state);
assert_eq!(state.status, "primary agent selection cleared");
assert_eq!(
crate::config::selected_primary_agent(&app.config.paths).unwrap(),
None
);
let raw: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&app.config.paths.settings_file).unwrap())
.unwrap();
assert_eq!(raw["selected_primary_agent"], serde_json::Value::Null);
}
#[test]
fn primary_agent_selection_persistence_reports_invalid_id_without_write() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(1);
let mut app = test_app(&temp, sender);
let mut state = state::MissionControlState::default();
app.persist_primary_agent_selection(Some("bad/id"), &mut state);
assert!(
state
.status
.contains("failed to save primary agent selection"),
"{}",
state.status
);
assert!(!app.config.paths.settings_file.exists());
}
#[test]
fn best_effort_drops_when_channel_is_full() {
let (sender, receiver) = bounded::<TuiEvent>(1);
sender.send(TuiEvent::Done).unwrap();
send_best_effort(&sender, TuiEvent::Error("drop".to_string()));
assert!(matches!(receiver.recv().unwrap(), TuiEvent::Done));
assert!(receiver.try_recv().is_err());
}
#[test]
fn critical_reports_saturation_without_deadlock() {
let (sender, _receiver) = bounded::<TuiEvent>(1);
sender.send(TuiEvent::Done).unwrap();
let error = send_critical(&sender, TuiEvent::Done)
.unwrap_err()
.to_string();
assert!(error.contains("tui event channel blocked or closed"));
}
#[test]
fn next_ui_timer_timeout_uses_cursor_blink_due_time() {
let state = state::MissionControlState {
focus_pane: state::TuiFocusPane::Prompt,
..Default::default()
};
let now = Instant::now();
let last_blink = now - Duration::from_millis(200);
assert_eq!(
next_ui_timer_timeout(&state, last_blink, now, now),
Some(PROMPT_CURSOR_BLINK_INTERVAL - Duration::from_millis(200))
);
}
#[test]
fn next_ui_timer_timeout_uses_footer_git_branch_refresh_due_time() {
let state = state::MissionControlState {
focus_pane: state::TuiFocusPane::Transcript,
..Default::default()
};
let now = Instant::now();
assert_eq!(
next_ui_timer_timeout(&state, now, now, now),
Some(FOOTER_GIT_BRANCH_REFRESH_INTERVAL)
);
}
#[test]
fn prompt_cursor_blink_due_only_when_prompt_cursor_can_change() {
let mut state = state::MissionControlState {
focus_pane: state::TuiFocusPane::Prompt,
..Default::default()
};
let old_blink = Instant::now() - PROMPT_CURSOR_BLINK_INTERVAL;
assert!(prompt_cursor_blink_due(&state, old_blink));
state.focus_pane = state::TuiFocusPane::Transcript;
assert!(!prompt_cursor_blink_due(&state, old_blink));
state.focus_prompt();
state.input = "typed".to_string();
assert!(!prompt_cursor_blink_due(&state, old_blink));
}
#[test]
fn redraw_intent_draws_immediate_and_scroll() {
assert!(should_draw_frame(RedrawIntent::Immediate));
assert!(should_draw_frame(RedrawIntent::ScrollOnly));
assert!(!should_draw_frame(RedrawIntent::None));
}
#[test]
fn activity_delta_drain_applies_all_queued_events_without_visible_node_gate() {
let (sender, receiver) = bounded::<TuiEvent>(4);
let id = crate::output::ActivityId::new("tool-1");
sender
.send(TuiEvent::Activity(ActivityEvent::Started {
id: id.clone(),
parent_id: None,
kind: crate::output::ActivityKind::Tool,
status: ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("running"),
}))
.unwrap();
sender
.send(TuiEvent::Activity(ActivityEvent::Delta {
id: id.clone(),
preview: "a".to_string(),
}))
.unwrap();
sender
.send(TuiEvent::Activity(ActivityEvent::Delta {
id: id.clone(),
preview: "b".to_string(),
}))
.unwrap();
sender
.send(TuiEvent::Activity(ActivityEvent::Finished {
id: id.clone(),
status: ActivityStatus::Success,
metadata: None,
}))
.unwrap();
let mut state = state::MissionControlState::default();
let result = drain_tui_events(&receiver, &mut state);
let node = state.nodes.get(&id).unwrap();
assert!(result.changed);
assert_eq!(node.preview, "ab");
assert_eq!(node.status, ActivityStatus::Success);
}
#[test]
fn activity_structural_events_drain_without_pacing() {
let (sender, receiver) = bounded::<TuiEvent>(3);
for index in 0..3 {
sender
.send(TuiEvent::Activity(ActivityEvent::Started {
id: crate::output::ActivityId::new(format!("tool-{index}")),
parent_id: None,
kind: crate::output::ActivityKind::Tool,
status: ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new(format!("tool {index}")),
}))
.unwrap();
}
let mut state = state::MissionControlState::default();
let result = drain_tui_events(&receiver, &mut state);
assert!(result.changed);
assert_eq!(state.visible_nodes().len(), 3);
}
#[test]
fn activity_started_then_terminal_order_is_preserved() {
let (sender, receiver) = bounded::<TuiEvent>(2);
let id = crate::output::ActivityId::new("tool-1");
sender
.send(TuiEvent::Activity(ActivityEvent::Started {
id: id.clone(),
parent_id: None,
kind: crate::output::ActivityKind::Tool,
status: ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("running"),
}))
.unwrap();
sender
.send(TuiEvent::Activity(ActivityEvent::Finished {
id: id.clone(),
status: ActivityStatus::Success,
metadata: Some(crate::output::ActivityMetadata::new("done")),
}))
.unwrap();
let mut state = state::MissionControlState::default();
let _result = drain_tui_events(&receiver, &mut state);
assert_eq!(
state.nodes.get(&id).unwrap().status,
ActivityStatus::Success
);
}
#[test]
fn assistant_delta_drain_applies_full_text_without_visual_pacing() {
let (sender, receiver) = bounded::<TuiEvent>(1);
let text = "😀".repeat(34);
sender
.send(TuiEvent::Output(OutputEvent::AssistantDelta { text }))
.unwrap();
let mut state = state::MissionControlState::default();
drain_tui_events(&receiver, &mut state);
let streamed = state.transcript[0].trim_start_matches("assistant: ");
assert_eq!(streamed.chars().count(), 34);
}
#[test]
fn assistant_complete_after_delta_reconciles_without_duplication() {
let (sender, receiver) = bounded::<TuiEvent>(2);
let text = "x".repeat(40);
sender
.send(TuiEvent::Output(OutputEvent::AssistantDelta {
text: text.clone(),
}))
.unwrap();
sender
.send(TuiEvent::Output(OutputEvent::AssistantComplete {
text: text.clone(),
}))
.unwrap();
let mut state = state::MissionControlState::default();
let result = drain_tui_events(&receiver, &mut state);
assert!(result.changed);
assert_eq!(state.transcript, vec![format!("assistant: {text}")]);
}
#[test]
fn drain_tui_events_applies_activity_updates_without_effect_triggers() {
let (sender, receiver) = bounded::<TuiEvent>(4);
let id = crate::output::ActivityId::new("tool-1");
sender
.send(TuiEvent::Activity(ActivityEvent::Started {
id: id.clone(),
parent_id: None,
kind: crate::output::ActivityKind::Tool,
status: ActivityStatus::Queued,
metadata: crate::output::ActivityMetadata::new("queued"),
}))
.unwrap();
sender
.send(TuiEvent::Activity(ActivityEvent::Started {
id: id.clone(),
parent_id: None,
kind: crate::output::ActivityKind::Tool,
status: ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("running"),
}))
.unwrap();
sender
.send(TuiEvent::Activity(ActivityEvent::Delta {
id: id.clone(),
preview: "ignored for movement".to_string(),
}))
.unwrap();
sender
.send(TuiEvent::Activity(ActivityEvent::FinalPreview {
id: id.clone(),
preview: "preview only".to_string(),
metadata: None,
status: None,
}))
.unwrap();
let mut state = state::MissionControlState::default();
let _result = drain_tui_events(&receiver, &mut state);
assert_eq!(
state.nodes.get(&id).unwrap().status,
ActivityStatus::Running
);
assert_eq!(state.nodes.get(&id).unwrap().preview, "preview only");
}
#[test]
fn drain_tui_events_applies_selected_metadata_refresh() {
let (sender, receiver) = bounded::<TuiEvent>(1);
let id = crate::output::ActivityId::new("tool-1");
let mut state = state::MissionControlState::default();
state.apply_activity_event(ActivityEvent::Started {
id: id.clone(),
parent_id: None,
kind: crate::output::ActivityKind::Tool,
status: ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("before"),
});
state.selected = 0;
sender
.send(TuiEvent::Activity(ActivityEvent::FinalPreview {
id: id.clone(),
preview: "metadata refresh".to_string(),
metadata: Some(crate::output::ActivityMetadata::new("after")),
status: Some(ActivityStatus::Running),
}))
.unwrap();
let _result = drain_tui_events(&receiver, &mut state);
assert_eq!(state.nodes.get(&id).unwrap().metadata.label, "after");
}
#[test]
fn drain_tui_events_applies_all_pending_events() {
let (sender, receiver) = bounded::<TuiEvent>(8);
sender
.send(TuiEvent::Output(
crate::output::OutputEvent::AssistantDelta {
text: "fast".to_string(),
},
))
.unwrap();
sender
.send(TuiEvent::Output(
crate::output::OutputEvent::AssistantComplete {
text: "fast enough".to_string(),
},
))
.unwrap();
sender
.send(TuiEvent::Error("running prompt…".to_string()))
.unwrap();
sender.send(TuiEvent::Done).unwrap();
let mut state = state::MissionControlState::default();
let result = drain_tui_events(&receiver, &mut state);
assert!(result.changed);
assert!(!result.run_finished);
assert_eq!(state.transcript, vec!["assistant: fast enough".to_string()]);
assert_eq!(state.status, "run complete");
assert!(receiver.try_recv().is_err());
assert_eq!(
drain_tui_events(&receiver, &mut state),
DrainResult::default()
);
}
#[test]
fn drain_tui_events_reports_run_finished_without_overwriting_status() {
let (sender, receiver) = bounded::<TuiEvent>(2);
sender.send(TuiEvent::Error("failed".to_string())).unwrap();
sender
.send(TuiEvent::RunFinished { final_event: None })
.unwrap();
let mut state = state::MissionControlState::default();
let result = drain_tui_events(&receiver, &mut state);
assert!(result.changed);
assert!(result.run_finished);
assert_eq!(state.status, "failed");
}
#[test]
fn drain_tui_events_records_canceled_prompt_without_error_event() {
let (sender, receiver) = bounded::<TuiEvent>(2);
sender
.send(TuiEvent::RunCanceled {
prompt: "inspect repo".to_string(),
})
.unwrap();
sender
.send(TuiEvent::RunFinished { final_event: None })
.unwrap();
let mut state = state::MissionControlState::default();
state.start_running_prompt("inspect repo".to_string());
let result = drain_tui_events(&receiver, &mut state);
assert!(result.changed);
assert!(result.run_finished);
assert_eq!(state.status, "canceled: inspect repo");
assert_eq!(state.transcript, vec!["canceled: inspect repo".to_string()]);
assert!(state.running_prompt.is_none());
}
#[test]
fn provider_ready_stays_false_when_login_finishes_for_non_active_provider() {
let (sender, receiver) = bounded::<TuiEvent>(1);
sender
.send(TuiEvent::LoginFinished {
provider_id: crate::providers::OPENAI_CODEX_PROVIDER.to_string(),
result: Ok("OpenAI Codex configured".to_string()),
})
.unwrap();
let mut state = state::MissionControlState {
provider: "openai".to_string(),
provider_ready: false,
..Default::default()
};
state.start_active_login(crate::providers::OPENAI_CODEX_PROVIDER.to_string());
let result = drain_tui_events(&receiver, &mut state);
assert!(result.changed);
assert!(!state.provider_ready);
assert!(state.modals.active_login.is_none());
assert_eq!(state.status, "OpenAI Codex configured");
}
#[test]
fn provider_ready_becomes_false_when_refresh_fails_before_prompt_run() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
app.state.auth_state = crate::config::AuthState::Ready {
provider: crate::providers::OPENAI_CODEX_PROVIDER.to_string(),
credential: crate::config::ProviderCredential::OAuth {
access: "expired-token".to_string(),
account_id: Some("acct".to_string()),
},
};
let mut ui_state = state::MissionControlState {
provider: crate::providers::OPENAI_CODEX_PROVIDER.to_string(),
model: "test-model".to_string(),
provider_ready: true,
..Default::default()
};
app.submit("hello".to_string(), &mut ui_state, &receiver, test_area());
assert!(!ui_state.provider_ready);
assert!(matches!(
receiver.try_recv().unwrap(),
TuiEvent::Error(message)
if message.starts_with("openai-codex credential refresh failed:")
&& message.ends_with("; run /login openai-codex")
));
assert!(receiver.try_recv().is_err());
assert!(app.worker.is_none());
assert!(!app.active_run);
}
#[test]
fn slash_command_recognizers_use_command_token_after_trim() {
assert_eq!(slash_command_token("/new"), Some("/new"));
assert_eq!(slash_command_token(" /new please "), Some("/new"));
assert_eq!(slash_command_token("/new\ntext"), Some("/new"));
assert_eq!(slash_command_token("not /new"), None);
assert!(is_new_session_command("/new"));
assert!(is_new_session_command(" /new "));
assert!(is_new_session_command("/new please"));
assert!(is_new_session_command("/new\ntext"));
assert!(!is_new_session_command("/new-session"));
assert!(!is_new_session_command("/setmodel"));
assert!(is_quit_command(" /quit "));
assert!(is_quit_command(" /quit now "));
}
#[test]
fn prune_sessions_tui_command_captures_args_and_requires_idle() {
assert_eq!(
tui_submit_command(" /prune-sessions 7 ", &CommandRegistry::mvp()),
TuiSubmitCommand::PruneSessions(Some("7"))
);
assert_eq!(
tui_submit_command("/prune-sessions", &CommandRegistry::mvp()),
TuiSubmitCommand::PruneSessions(None)
);
let command = tui_submit_command("/prune-sessions 7", &CommandRegistry::mvp());
assert!(command.requires_idle());
assert_eq!(
command.active_run_error(),
Some("cannot prune sessions while a prompt is running")
);
}
#[test]
fn unsupported_slash_command_recognizer_treats_all_builtins_as_supported() {
for command in BuiltInCommand::ALL {
let prompt = format!(" /{} ignored ", command.name());
assert_eq!(unsupported_slash_command(&prompt), None, "{prompt}");
assert!(
!matches!(
tui_submit_command(&prompt, &CommandRegistry::mvp()),
TuiSubmitCommand::Unsupported(_)
),
"{prompt}"
);
}
}
#[test]
fn unsupported_slash_command_recognizer_keeps_new_model_skills_system_prompt_and_quit_tokens() {
assert_eq!(unsupported_slash_command("/compact keep file paths"), None);
assert_eq!(unsupported_slash_command("/setmodel gpt-next"), None);
assert_eq!(unsupported_slash_command("/skills"), None);
assert_eq!(unsupported_slash_command("/subagents"), None);
assert_eq!(unsupported_slash_command("/system-prompt"), None);
assert_eq!(unsupported_slash_command("/system-prompt ignored"), None);
assert_eq!(
unsupported_slash_command("/skill:review"),
Some("/skill:review")
);
assert_eq!(
unsupported_slash_command(" /missing arg "),
Some("/missing")
);
assert_eq!(unsupported_slash_command("/new"), None);
assert_eq!(unsupported_slash_command("/new extra"), None);
assert_eq!(unsupported_slash_command("/quit"), None);
assert_eq!(unsupported_slash_command("/quit extra"), None);
assert_eq!(unsupported_slash_command("hello"), None);
}
#[test]
fn compact_command_uses_override_without_refreshing_missing_active_codex_auth() {
let _env = crate::test_support::env::env_lock();
let temp = tempfile::TempDir::new().unwrap();
let session = SessionManager::new(temp.path().join("sessions"))
.create()
.unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app_with_active_session(&temp, sender, Some(session.clone()));
app.config.provider = Some(crate::providers::OPENAI_CODEX_PROVIDER.to_string());
app.config.model = Some("active-model".to_string());
app.config.auth = None;
app.state.config = Some(app.config.clone());
app.state.model = "active-model".to_string();
app.state.auth_state = crate::config::AuthState::Missing {
provider: crate::providers::OPENAI_CODEX_PROVIDER.to_string(),
};
let base_url = start_sse_server("tui override summary");
write_compaction_override_settings(
&app.config.paths,
"local-provider",
"summary-model",
&base_url,
);
let mut ui_state = state::MissionControlState {
provider: crate::providers::OPENAI_CODEX_PROVIDER.to_string(),
model: "active-model".to_string(),
provider_ready: false,
..Default::default()
};
app.start_compaction(None, &mut ui_state);
assert!(app.worker.is_some());
assert!(app.active_run);
assert!(!ui_state.provider_ready);
assert_eq!(ui_state.status, "compacting session…");
let worker = app.worker.take().unwrap();
worker.handle.join().unwrap();
let result = drain_tui_events(&receiver, &mut ui_state);
assert!(result.run_finished);
assert!(
ui_state.status.contains("compaction complete: session"),
"{}",
ui_state.status
);
let compaction_nodes = ui_state
.nodes
.values()
.filter(|node| node.kind == crate::output::ActivityKind::Compaction)
.collect::<Vec<_>>();
assert_eq!(compaction_nodes.len(), 1);
assert_eq!(compaction_nodes[0].status, ActivityStatus::Success);
assert_eq!(compaction_nodes[0].preview, "tui override summary");
let events = session.read_events().unwrap();
assert_eq!(
events.last().unwrap().payload["summary"],
"tui override summary"
);
assert_eq!(events.last().unwrap().payload["provider"], "local-provider");
assert_eq!(events.last().unwrap().payload["model"], "summary-model");
}
#[test]
fn submit_idle_only_commands_reject_while_provider_run_active() {
let cases = [
("/compact", "cannot compact while a prompt is running"),
("/login", "cannot start login while a prompt is running"),
("/logout", "cannot logout while a prompt is running"),
("/setmodel", "cannot change model while a prompt is running"),
(
"/mcp",
"cannot manage MCP servers while a prompt is running",
),
("/skills", "cannot manage skills while a prompt is running"),
(
"/subagents",
"cannot manage subagents while a prompt is running",
),
(
"/sessions",
"cannot switch sessions while a prompt is running",
),
(
"/new",
"cannot start a new session while a prompt is running",
),
];
for (prompt, expected) in cases {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
app.active_run = true;
let mut ui_state = state::MissionControlState::default();
app.submit(prompt.to_string(), &mut ui_state, &receiver, test_area());
assert!(matches!(
receiver.try_recv().unwrap(),
TuiEvent::Error(message) if message == expected
));
assert!(receiver.try_recv().is_err());
assert!(app.worker.is_none());
assert!(app.active_run, "{prompt}");
}
}
#[test]
fn submit_active_prompt_queues_steering_without_provider_submission() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let running = Arc::new(AtomicBool::new(true));
let thread_running = Arc::clone(&running);
let steering = crate::agent::steering::AgentSteering::default();
app.active_run = true;
app.worker = Some(WorkerState {
handle: thread::spawn(move || {
while thread_running.load(Ordering::SeqCst) {
thread::sleep(Duration::from_millis(1));
}
}),
cancel: Arc::new(AtomicBool::new(false)),
login_manual: None,
outcome: Arc::new(WorkerOutcomeState::default()),
outcome_reconciled: false,
steering: steering.clone(),
accepts_steering: true,
});
app.steering = steering.clone();
let mut ui_state = state::MissionControlState {
input: "adjust course".to_string(),
prompt_cursor: "adjust course".len(),
..Default::default()
};
let submitted = app.submit(
"adjust course".to_string(),
&mut ui_state,
&receiver,
test_area(),
);
assert!(!submitted);
assert_eq!(ui_state.status, "Steering queued");
assert_eq!(ui_state.pending_steering_count, 1);
assert!(ui_state.input.is_empty());
assert_eq!(ui_state.prompt_cursor, 0);
assert_eq!(steering.pending_count(), 1);
assert_eq!(
steering.drain_collapsed(),
Some(
"Steering update from user while current run was active:\n\nadjust course".to_string()
)
);
assert!(receiver.try_recv().is_err());
running.store(false, Ordering::SeqCst);
let worker = app.worker.take().unwrap();
worker.handle.join().unwrap();
}
#[test]
fn bash_mode_active_submit_preserves_input_without_queueing_steering() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let running = Arc::new(AtomicBool::new(true));
let thread_running = Arc::clone(&running);
let steering = crate::agent::steering::AgentSteering::default();
app.active_run = true;
app.worker = Some(WorkerState {
handle: thread::spawn(move || {
while thread_running.load(Ordering::SeqCst) {
thread::sleep(Duration::from_millis(1));
}
}),
cancel: Arc::new(AtomicBool::new(false)),
login_manual: None,
outcome: Arc::new(WorkerOutcomeState::default()),
outcome_reconciled: false,
steering: steering.clone(),
accepts_steering: false,
});
app.steering = steering.clone();
let mut ui_state = state::MissionControlState {
input: "keep this draft".to_string(),
prompt_cursor: "keep this draft".len(),
..Default::default()
};
let submitted = app.submit(
"keep this draft".to_string(),
&mut ui_state,
&receiver,
test_area(),
);
assert!(!submitted);
assert_eq!(
ui_state.status,
"active run cannot accept steering; wait for it to finish"
);
assert_eq!(ui_state.input, "keep this draft");
assert_eq!(ui_state.prompt_cursor, "keep this draft".len());
assert_eq!(ui_state.pending_steering_count, 0);
assert_eq!(steering.pending_count(), 0);
assert!(receiver.try_recv().is_err());
running.store(false, Ordering::SeqCst);
let worker = app.worker.take().unwrap();
worker.handle.join().unwrap();
}
#[test]
fn cancel_active_worker_clears_pending_steering_for_finished_worker() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(2);
let mut app = test_app(&temp, sender);
app.active_run = true;
let cancel = Arc::new(AtomicBool::new(false));
let steering = crate::agent::steering::AgentSteering::default();
steering.try_enqueue("stale steering".to_string()).unwrap();
app.steering = steering.clone();
app.last_steering_pending_count = 1;
app.worker = Some(WorkerState {
handle: thread::spawn(|| {}),
cancel,
login_manual: None,
outcome: Arc::new(WorkerOutcomeState::default()),
outcome_reconciled: false,
steering: steering.clone(),
accepts_steering: true,
});
while !app.worker.as_ref().unwrap().handle.is_finished() {
thread::yield_now();
}
let mut ui_state = state::MissionControlState {
pending_steering_count: 1,
..Default::default()
};
let status = app.cancel_active_worker(&mut ui_state);
assert!(status.is_none());
assert_eq!(steering.pending_count(), 0);
assert_eq!(ui_state.pending_steering_count, 0);
assert_eq!(app.last_steering_pending_count, 0);
assert!(app.worker.is_none());
}
#[test]
fn cancel_active_worker_returns_before_join_grace_while_worker_shuts_down() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(2);
let mut app = test_app(&temp, sender);
app.active_run = true;
let cancel = Arc::new(AtomicBool::new(false));
let worker_cancel = cancel.clone();
let steering = crate::agent::steering::AgentSteering::default();
steering.try_enqueue("stale steering".to_string()).unwrap();
app.steering = steering.clone();
app.last_steering_pending_count = 1;
app.worker = Some(WorkerState {
handle: thread::spawn(move || {
while !worker_cancel.load(Ordering::SeqCst) {
thread::sleep(Duration::from_millis(1));
}
thread::sleep(CANCEL_JOIN_GRACE_PERIOD + Duration::from_millis(25));
}),
cancel,
login_manual: None,
outcome: Arc::new(WorkerOutcomeState::default()),
outcome_reconciled: false,
steering: steering.clone(),
accepts_steering: true,
});
let mut ui_state = state::MissionControlState {
pending_steering_count: 1,
..Default::default()
};
let started = Instant::now();
let status = app.cancel_active_worker(&mut ui_state);
assert!(started.elapsed() < CANCEL_JOIN_GRACE_PERIOD);
assert!(status.is_some());
assert_eq!(steering.pending_count(), 0);
assert_eq!(ui_state.pending_steering_count, 0);
assert_eq!(app.last_steering_pending_count, 0);
assert!(app.worker.is_some());
while !app.worker.as_ref().unwrap().handle.is_finished() {
thread::yield_now();
}
assert!(app.finish_worker_if_ready(&mut ui_state));
assert_eq!(steering.pending_count(), 0);
assert_eq!(ui_state.pending_steering_count, 0);
}
#[test]
fn join_worker_timeout_sets_blocking_status_and_keeps_worker_attached() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(2);
let mut app = test_app(&temp, sender);
app.active_run = true;
let running = Arc::new(AtomicBool::new(true));
let thread_running = Arc::clone(&running);
app.worker = Some(WorkerState {
handle: thread::spawn(move || {
while thread_running.load(Ordering::SeqCst) {
thread::sleep(Duration::from_millis(1));
}
}),
cancel: Arc::new(AtomicBool::new(false)),
login_manual: None,
outcome: Arc::new(WorkerOutcomeState::default()),
outcome_reconciled: false,
steering: crate::agent::steering::AgentSteering::default(),
accepts_steering: true,
});
let mut ui_state = state::MissionControlState::default();
assert!(!app.join_worker_with_timeout(&mut ui_state, Duration::from_millis(5)));
assert!(app.worker.is_some());
assert!(
ui_state.status.contains("exit blocked"),
"{}",
ui_state.status
);
assert!(!ui_state.status.contains("detach"), "{}", ui_state.status);
running.store(false, Ordering::SeqCst);
assert!(app.join_worker_with_timeout(&mut ui_state, Duration::from_secs(1)));
}
#[test]
fn cancel_running_prompt_sets_visible_status_before_worker_reconciliation() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(2);
let mut app = test_app(&temp, sender);
app.active_run = true;
let cancel = Arc::new(AtomicBool::new(false));
let worker_cancel = cancel.clone();
app.worker = Some(WorkerState {
handle: thread::spawn(move || {
while !worker_cancel.load(Ordering::SeqCst) {
thread::sleep(Duration::from_millis(1));
}
thread::sleep(CANCEL_JOIN_GRACE_PERIOD + Duration::from_millis(25));
}),
cancel,
login_manual: None,
outcome: Arc::new(WorkerOutcomeState::default()),
outcome_reconciled: false,
steering: crate::agent::steering::AgentSteering::default(),
accepts_steering: true,
});
let mut ui_state = state::MissionControlState::default();
ui_state.start_running_prompt("inspect repo".to_string());
let started = Instant::now();
app.cancel_running_prompt(&mut ui_state);
assert!(started.elapsed() < CANCEL_JOIN_GRACE_PERIOD);
assert_eq!(ui_state.status, "canceling: inspect repo");
assert!(matches!(
ui_state.running_prompt.as_ref().map(|prompt| prompt.status),
Some(state::RunningPromptStatus::Canceling)
));
assert!(app.worker.is_some());
while !app.worker.as_ref().unwrap().handle.is_finished() {
thread::yield_now();
}
assert!(app.finish_worker_if_ready(&mut ui_state));
}
#[test]
fn submit_active_prompt_reports_full_steering_queue_and_restores_prompt() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let running = Arc::new(AtomicBool::new(true));
let thread_running = Arc::clone(&running);
let steering = crate::agent::steering::AgentSteering::default();
for index in 0..16 {
steering.try_enqueue(format!("message {index}")).unwrap();
}
app.active_run = true;
app.worker = Some(WorkerState {
handle: thread::spawn(move || {
while thread_running.load(Ordering::SeqCst) {
thread::sleep(Duration::from_millis(1));
}
}),
cancel: Arc::new(AtomicBool::new(false)),
login_manual: None,
outcome: Arc::new(WorkerOutcomeState::default()),
outcome_reconciled: false,
steering: steering.clone(),
accepts_steering: true,
});
app.steering = steering.clone();
let mut ui_state = state::MissionControlState::default();
let submitted = app.submit(
"overflow".to_string(),
&mut ui_state,
&receiver,
test_area(),
);
assert!(!submitted);
assert_eq!(ui_state.status, "Steering queue full (16 max)");
assert_eq!(ui_state.input, "overflow");
assert_eq!(ui_state.prompt_cursor, "overflow".len());
assert_eq!(steering.pending_count(), 16);
assert!(matches!(
receiver.try_recv().unwrap(),
TuiEvent::Error(message) if message == "Steering queue full (16 max)"
));
assert!(receiver.try_recv().is_err());
running.store(false, Ordering::SeqCst);
let worker = app.worker.take().unwrap();
worker.handle.join().unwrap();
}
#[test]
fn steering_pending_drop_shows_injected_feedback_and_clears_count() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let steering = crate::agent::steering::AgentSteering::default();
steering.try_enqueue("adjust".to_string()).unwrap();
app.steering = steering.clone();
app.last_steering_pending_count = 1;
let mut ui_state = state::MissionControlState {
pending_steering_count: 1,
..Default::default()
};
assert!(steering.drain_collapsed().is_some());
let changed = app.sync_steering_feedback(&mut ui_state);
assert!(changed);
assert_eq!(ui_state.pending_steering_count, 0);
assert_eq!(ui_state.status, "Steering injected");
assert!(matches!(
ui_state.toast.as_ref().map(|toast| toast.message.as_str()),
Some("Steering injected")
));
}
#[test]
fn provider_worker_gets_steering_handle_without_queuing_submit_input() {
let temp = tempfile::TempDir::new().unwrap();
let base_url = start_sse_server("tui response");
let (sender, receiver) = bounded::<TuiEvent>(8);
let mut app = test_app(&temp, sender);
let provider_id = "local-ai".to_string();
app.config.provider = Some(provider_id.clone());
app.config.model = Some("test-model".to_string());
app.config.custom_providers = std::collections::BTreeMap::from([(
provider_id.clone(),
crate::config::make_custom_provider_config("Local", &base_url, "").unwrap(),
)]);
app.config.auth = Some(crate::config::ProviderCredential::NoAuth);
app.state.config = Some(app.config.clone());
app.state.auth_state = crate::config::AuthState::Ready {
provider: provider_id,
credential: crate::config::ProviderCredential::NoAuth,
};
let mut ui_state = state::MissionControlState::default();
let submitted = app.submit("hello".to_string(), &mut ui_state, &receiver, test_area());
assert!(submitted);
let steering = app.worker.as_ref().unwrap().steering.clone();
assert_eq!(steering.pending_count(), 0);
let worker = app.worker.take().unwrap();
worker.handle.join().unwrap();
}
#[test]
fn submit_help_opens_help_without_worker_or_provider_run() {
assert_eq!(
tui_submit_command(" /help ignored ", &CommandRegistry::mvp()),
TuiSubmitCommand::Help
);
assert!(!tui_submit_command("/help", &CommandRegistry::mvp()).requires_idle());
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
app.active_run = true;
let mut ui_state = state::MissionControlState {
..Default::default()
};
let submitted = app.submit("/help".to_string(), &mut ui_state, &receiver, test_area());
assert!(!submitted);
assert!(ui_state.show_help);
assert_eq!(ui_state.help_offset(), 0);
assert_eq!(ui_state.status, "showing help");
assert!(app.active_run);
assert!(app.worker.is_none());
assert!(receiver.try_recv().is_err());
}
#[test]
fn submit_system_prompt_and_quit_preserve_active_run_bypass_behavior() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
app.active_run = true;
let mut ui_state = state::MissionControlState::default();
app.submit(
"/system-prompt".to_string(),
&mut ui_state,
&receiver,
test_area(),
);
assert!(ui_state.system_prompt_modal_visible());
assert!(app.active_run);
assert!(app.worker.is_none());
assert!(receiver.try_recv().is_err());
app.submit("/quit".to_string(), &mut ui_state, &receiver, test_area());
assert!(app.active_run);
assert!(app.worker.is_none());
assert!(receiver.try_recv().is_err());
}
#[test]
fn submit_system_prompt_opens_modal_without_worker_or_session_event() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let session = SessionManager::new(temp.path().join("sessions"))
.create()
.unwrap();
let paths = test_paths(temp.path());
fs::create_dir_all(&paths.subagents).unwrap();
fs::write(
paths.subagents.join("reviewer.md"),
"---\nname: Reviewer\ndescription: Code review\n---\nSECRET REVIEW PROMPT\n",
)
.unwrap();
let mut app = test_app_with_active_session(&temp, sender, Some(session.clone()));
let mut ui_state = state::MissionControlState {
input: "preserved draft".to_string(),
prompt_cursor: "preserved draft".len(),
transcript: vec!["existing transcript".to_string()].into(),
model: "test-model".to_string(),
provider_ready: false,
..Default::default()
};
ui_state.set_scroll_offset(&ui_state.scroll_views.detail, 3);
app.submit(
"/system-prompt".to_string(),
&mut ui_state,
&receiver,
test_area(),
);
let modal = ui_state.modals.system_prompt_modal.as_ref().unwrap();
assert!(!modal.content.trim().is_empty());
assert!(modal.content.contains("tokens /"));
assert!(modal.content.contains("max ("));
assert!(modal.content.contains("Available tools:"));
assert!(modal.content.contains("<Subagent-Identities>"));
assert!(modal.content.contains("- `reviewer` — Code review"));
let tools_index = modal.content.find("Available tools:").unwrap();
let subagents_index = modal.content.find("<Subagent-Identities>").unwrap();
let skills_index = modal.content.find("<Available-Skills>").unwrap();
let instructions_index = modal
.content
.find("No AGENTS.md instruction files")
.unwrap();
assert!(tools_index < subagents_index);
assert!(subagents_index < skills_index);
assert!(skills_index < instructions_index);
assert!(!modal.content.contains("SECRET REVIEW PROMPT"));
assert_eq!(
ui_state.scroll_offset(&ui_state.scroll_views.system_prompt),
0
);
assert_eq!(ui_state.input, "preserved draft");
assert_eq!(ui_state.transcript, vec!["existing transcript"]);
assert_eq!(ui_state.detail_offset(), 3);
assert!(!app.active_run);
assert!(app.worker.is_none());
assert!(receiver.try_recv().is_err());
assert!(session.read_events().unwrap().is_empty());
}
#[test]
fn submit_mcp_opens_modal_with_settings_rows() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
crate::config::write_settings(
&app.config.paths,
&crate::config::Settings {
mcp_servers: std::collections::BTreeMap::from([
(
"alpha".to_string(),
crate::config::McpServerConfig::Stdio(crate::config::McpStdioServerConfig {
command: "alpha-mcp".to_string(),
args: Vec::new(),
env: std::collections::BTreeMap::new(),
enabled: true,
timeout: None,
}),
),
(
"beta".to_string(),
crate::config::McpServerConfig::Http(crate::config::McpHttpServerConfig {
url: "https://mcp.example.test/beta".to_string(),
headers: std::collections::BTreeMap::new(),
oauth: None,
enabled: false,
timeout: None,
}),
),
]),
..Default::default()
},
)
.unwrap();
let mut ui_state = state::MissionControlState::default();
let submitted = app.submit("/mcp".to_string(), &mut ui_state, &receiver, test_area());
assert!(!submitted);
assert_eq!(ui_state.status, "showing MCP servers");
let modal = ui_state.modals.mcp_modal.as_ref().unwrap();
assert_eq!(modal.rows.len(), 2);
assert_eq!(modal.rows[0].name, "alpha");
assert_eq!(modal.rows[0].kind, "stdio");
assert!(modal.rows[0].enabled);
assert_eq!(modal.rows[1].name, "beta");
assert_eq!(modal.rows[1].kind, "http");
assert!(!modal.rows[1].enabled);
assert!(app.worker.is_none());
assert!(!app.active_run);
assert!(receiver.try_recv().is_err());
}
#[test]
fn submit_mcp_extra_reports_usage_without_opening_modal() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let mut ui_state = state::MissionControlState::default();
let submitted = app.submit(
"/mcp extra".to_string(),
&mut ui_state,
&receiver,
test_area(),
);
assert!(!submitted);
assert_eq!(ui_state.status, "usage: /mcp");
assert!(!ui_state.mcp_modal_visible());
assert!(app.worker.is_none());
assert!(!app.active_run);
assert!(receiver.try_recv().is_err());
}
#[test]
fn set_mcp_server_enabled_persists_setting() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(1);
let mut app = test_app(&temp, sender);
crate::config::write_settings(
&app.config.paths,
&crate::config::Settings {
mcp_servers: std::collections::BTreeMap::from([(
"alpha".to_string(),
crate::config::McpServerConfig::Stdio(crate::config::McpStdioServerConfig {
command: "alpha-mcp".to_string(),
args: Vec::new(),
env: std::collections::BTreeMap::new(),
enabled: true,
timeout: None,
}),
)]),
..Default::default()
},
)
.unwrap();
let mut ui_state = state::MissionControlState::default();
app.set_mcp_server_enabled("alpha", false, &mut ui_state);
assert_eq!(
ui_state.status,
"MCP server disabled: alpha; takes effect now"
);
let settings = crate::config::read_settings(&app.config.paths).unwrap();
assert!(!settings.mcp_servers.get("alpha").unwrap().enabled());
let raw: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&app.config.paths.settings_file).unwrap())
.unwrap();
assert_eq!(raw["mcp_servers"]["alpha"]["enabled"], false);
}
#[test]
fn failed_mcp_server_enabled_save_rolls_back_optimistic_row() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(1);
let mut app = test_app(&temp, sender);
crate::config::write_settings(&app.config.paths, &crate::config::Settings::default()).unwrap();
let mut ui_state = state::MissionControlState::default();
ui_state.open_mcp_modal(
vec![state::McpServerToggleRow {
name: "missing-server".to_string(),
kind: "stdio",
enabled: false,
}],
1,
);
app.set_mcp_server_enabled("missing-server", false, &mut ui_state);
let modal = ui_state.modals.mcp_modal.as_ref().unwrap();
assert!(modal.rows[0].enabled);
assert!(
ui_state
.status
.contains("failed to save MCP server setting: mcp server not found: missing-server"),
"{}",
ui_state.status
);
}
#[test]
fn submit_compact_requires_active_session_and_creates_no_worker() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let mut ui_state = state::MissionControlState::default();
app.submit(
"/compact".to_string(),
&mut ui_state,
&receiver,
test_area(),
);
assert!(matches!(
receiver.try_recv().unwrap(),
TuiEvent::Error(message) if message.contains("/compact requires an active persisted session")
));
}
#[test]
fn compaction_started_pushes_compact_started_transcript() {
let mut ui_state = state::MissionControlState::default();
ui_state.record_compaction_started_transcript();
assert_eq!(ui_state.transcript.len(), 1);
assert_eq!(ui_state.transcript[0], "compact: started");
}
#[test]
fn compaction_activity_lifecycle_updates_single_node() {
let id = crate::output::ActivityId::new("compaction:session-1:1");
let mut ui_state = state::MissionControlState::default();
let mut metadata = crate::output::ActivityMetadata::new("compaction");
metadata.fields = vec![
("session_id".to_string(), "session-1".to_string()),
("provider".to_string(), "local".to_string()),
("model".to_string(), "compact-model".to_string()),
];
ui_state.apply_activity_event(ActivityEvent::Started {
id: id.clone(),
parent_id: None,
kind: crate::output::ActivityKind::Compaction,
status: ActivityStatus::Running,
metadata: metadata.clone(),
});
ui_state.apply_activity_event(ActivityEvent::FinalPreview {
id: id.clone(),
preview: "summary text".to_string(),
metadata: None,
status: Some(ActivityStatus::Success),
});
ui_state.apply_activity_event(ActivityEvent::Finished {
id: id.clone(),
status: ActivityStatus::Success,
metadata: Some(metadata),
});
assert_eq!(ui_state.roots, vec![id.clone()]);
let node = ui_state.nodes.get(&id).unwrap();
assert_eq!(node.kind, crate::output::ActivityKind::Compaction);
assert_eq!(node.status, ActivityStatus::Success);
assert_eq!(node.preview, "summary text");
let detail = ui_state.selected_activity_text();
assert!(detail.contains("type: compaction"), "{detail}");
assert!(detail.contains("summary text"), "{detail}");
}
#[test]
fn compaction_activity_finished_failed_without_duplicate_node() {
let id = crate::output::ActivityId::new("compaction:session-1:2");
let mut ui_state = state::MissionControlState::default();
ui_state.apply_activity_event(ActivityEvent::Started {
id: id.clone(),
parent_id: None,
kind: crate::output::ActivityKind::Compaction,
status: ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("compaction"),
});
ui_state.apply_activity_event(ActivityEvent::Finished {
id: id.clone(),
status: ActivityStatus::Failed,
metadata: None,
});
assert_eq!(ui_state.roots, vec![id.clone()]);
assert_eq!(ui_state.nodes.len(), 1);
assert_eq!(
ui_state.nodes.get(&id).unwrap().status,
ActivityStatus::Failed
);
}
#[test]
fn drain_stale_tui_events_discards_queued_previous_run_events() {
let (sender, receiver) = bounded::<TuiEvent>(8);
sender
.send(TuiEvent::Output(OutputEvent::UserPrompt {
text: "old".to_string(),
}))
.unwrap();
sender
.send(TuiEvent::Activity(ActivityEvent::Started {
id: crate::output::ActivityId::new("old-tool"),
parent_id: None,
kind: crate::output::ActivityKind::Tool,
status: crate::output::ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("old tool"),
}))
.unwrap();
sender.send(TuiEvent::Done).unwrap();
sender
.send(TuiEvent::RunFinished { final_event: None })
.unwrap();
let result = drain_stale_tui_events(&receiver);
assert!(result.changed);
assert!(result.run_finished);
assert!(receiver.try_recv().is_err());
}
#[test]
fn submit_new_rejects_while_provider_run_active() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
app.active_run = true;
let mut ui_state = state::MissionControlState::default();
app.submit("/new".to_string(), &mut ui_state, &receiver, test_area());
assert!(app.state.current_session.is_none());
assert!(matches!(
receiver.try_recv().unwrap(),
TuiEvent::Error(message)
if message == "cannot start a new session while a prompt is running"
));
}
#[test]
fn submit_new_drains_stale_events_resets_ui_and_creates_no_worker() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(8);
let mut app = test_app(&temp, sender.clone());
let mut ui_state = state::MissionControlState {
..Default::default()
};
ui_state.apply_output_event(&OutputEvent::UserPrompt {
text: "visible old".to_string(),
});
ui_state.apply_activity_event(ActivityEvent::Started {
id: crate::output::ActivityId::new("visible-old-tool"),
parent_id: None,
kind: crate::output::ActivityKind::Tool,
status: crate::output::ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("visible old tool"),
});
sender
.send(TuiEvent::Output(OutputEvent::UserPrompt {
text: "queued old".to_string(),
}))
.unwrap();
sender
.send(TuiEvent::Activity(ActivityEvent::Started {
id: crate::output::ActivityId::new("queued-old-tool"),
parent_id: None,
kind: crate::output::ActivityKind::Tool,
status: crate::output::ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("queued old tool"),
}))
.unwrap();
sender.send(TuiEvent::Done).unwrap();
sender
.send(TuiEvent::RunFinished { final_event: None })
.unwrap();
app.submit(
" /new ".to_string(),
&mut ui_state,
&receiver,
test_area(),
);
let status = ui_state.status.clone();
let drain_after_submit = drain_tui_events(&receiver, &mut ui_state);
assert!(status.starts_with("new session: "));
assert_eq!(ui_state.status, status);
assert!(ui_state.transcript.is_empty());
assert_eq!(ui_state.selected_activity_text(), "No activity selected.");
assert!(ui_state.nodes.is_empty());
assert_eq!(ui_state.help_offset(), 0);
assert!(app.state.current_session.is_some());
assert!(app.worker.is_none());
assert!(!app.active_run);
assert_eq!(drain_after_submit, DrainResult::default());
}
#[test]
fn submit_new_with_args_resets_ui_and_creates_no_worker() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let mut ui_state = state::MissionControlState::default();
ui_state.apply_output_event(&OutputEvent::UserPrompt {
text: "old".to_string(),
});
app.submit(
"/new extra".to_string(),
&mut ui_state,
&receiver,
test_area(),
);
assert!(ui_state.status.starts_with("new session: "));
assert!(ui_state.transcript.is_empty());
assert!(app.state.current_session.is_some());
assert!(app.worker.is_none());
assert!(!app.active_run);
assert_eq!(
drain_tui_events(&receiver, &mut ui_state),
DrainResult::default()
);
}
#[test]
fn submit_quit_with_args_creates_no_worker() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let mut ui_state = state::MissionControlState::default();
app.submit(
"/quit extra".to_string(),
&mut ui_state,
&receiver,
test_area(),
);
assert!(app.worker.is_none());
assert!(!app.active_run);
assert!(receiver.try_recv().is_err());
}
#[test]
fn submit_invalid_model_command_reports_error_and_creates_no_worker() {
let temp = tempfile::TempDir::new().unwrap();
let manager = SessionManager::new(temp.path().join("sessions"));
let session = manager.create().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app_with_active_session(&temp, sender, Some(session.clone()));
let mut ui_state = state::MissionControlState::default();
app.submit(
"/setmodel gpt-next".to_string(),
&mut ui_state,
&receiver,
test_area(),
);
assert!(matches!(
receiver.try_recv().unwrap(),
TuiEvent::Error(message) if message == "model id must use provider/model-name format"
));
assert!(receiver.try_recv().is_err());
assert!(app.worker.is_none());
assert!(!app.active_run);
let events = session.read_events().unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].event_type, "diagnostic");
assert_eq!(
events[0].payload["message"],
"model id must use provider/model-name format"
);
}
#[test]
fn submit_model_picker_rejects_fresh_codex_cache_when_active_provider_is_openai() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
crate::model_catalog::write_catalog_cache(
&app.config.paths,
crate::providers::OPENAI_CODEX_PROVIDER,
&[crate::model_catalog::ModelCatalogEntry::new_codex(
"gpt-next",
)],
)
.unwrap();
let mut ui_state = state::MissionControlState {
provider: "openai".to_string(),
model: "test-model".to_string(),
..Default::default()
};
app.submit(
"/setmodel".to_string(),
&mut ui_state,
&receiver,
test_area(),
);
assert!(ui_state.status.contains("loading model catalogs"));
assert!(ui_state.modals.model_picker.is_none());
assert_eq!(ui_state.model, "test-model");
assert!(receiver.try_recv().is_err());
assert!(app.worker.is_none());
assert!(!app.active_run);
}
#[test]
fn tui_claude_code_login_shows_setup_instructions_without_worker() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let mut ui_state = state::MissionControlState::default();
app.start_login(crate::providers::CLAUDE_CODE_PROVIDER, &mut ui_state);
assert!(
ui_state.status.contains("claude auth login"),
"{}",
ui_state.status
);
assert!(
ui_state.status.contains("Claude Code-credentials"),
"{}",
ui_state.status
);
assert!(
ui_state.status.contains("provider-keyed API key"),
"{}",
ui_state.status
);
assert!(app.worker.is_none());
assert!(!app.active_run);
assert!(receiver.try_recv().is_err());
}
#[test]
fn tui_login_picker_claude_code_uses_setup_status_without_oauth_error() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let mut ui_state = state::MissionControlState::default();
app.open_login_picker(&mut ui_state, test_area());
let picker = ui_state.modals.login_picker.as_ref().unwrap();
let claude = picker
.entries
.iter()
.find(|entry| entry.id == crate::providers::CLAUDE_CODE_PROVIDER)
.unwrap();
assert_eq!(claude.status, crate::login::CLAUDE_CODE_LOGIN_STATUS);
assert_eq!(ui_state.status, "select login provider");
}
#[test]
fn tui_custom_provider_login_setup_persists_metadata_without_worker() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let mut ui_state = state::MissionControlState::default();
app.start_login(crate::login::CUSTOM_PROVIDER_LOGIN_ID, &mut ui_state);
assert!(ui_state.modals.custom_provider_setup.is_some());
assert!(ui_state.status.contains("custom provider setup"));
assert!(app.worker.is_none());
assert!(!app.active_run);
ui_state
.modals
.custom_provider_setup
.as_mut()
.unwrap()
.label = "Local AI".to_string();
app.submit_custom_provider_setup_step(&mut ui_state);
ui_state
.modals
.custom_provider_setup
.as_mut()
.unwrap()
.base_url = "http://localhost:8080/v1".to_string();
app.submit_custom_provider_setup_step(&mut ui_state);
ui_state
.modals
.custom_provider_setup
.as_mut()
.unwrap()
.api_key_env_var = "LOCAL_AI_KEY".to_string();
app.submit_custom_provider_setup_step(&mut ui_state);
assert!(ui_state.modals.custom_provider_setup.is_none());
assert!(
ui_state
.status
.contains("Custom provider 'Local AI' (local-ai) configured")
);
let settings = crate::config::read_settings(&app.config.paths).unwrap();
let provider = settings.custom_providers.get("local-ai").unwrap();
assert_eq!(provider.label, "Local AI");
assert_eq!(provider.base_url, "http://localhost:8080/v1");
assert_eq!(provider.api_key_env_var.as_deref(), Some("LOCAL_AI_KEY"));
assert!(receiver.try_recv().is_err());
}
#[test]
fn tui_custom_provider_login_blank_env_var_persists_no_auth_without_export_instruction() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let mut ui_state = state::MissionControlState::default();
app.start_login(crate::login::CUSTOM_PROVIDER_LOGIN_ID, &mut ui_state);
ui_state
.modals
.custom_provider_setup
.as_mut()
.unwrap()
.label = "Local Llama".to_string();
app.submit_custom_provider_setup_step(&mut ui_state);
ui_state
.modals
.custom_provider_setup
.as_mut()
.unwrap()
.base_url = "http://localhost:11434/v1".to_string();
app.submit_custom_provider_setup_step(&mut ui_state);
app.submit_custom_provider_setup_step(&mut ui_state);
assert!(ui_state.modals.custom_provider_setup.is_none());
assert!(ui_state.status.contains("API key not required"));
assert!(!ui_state.status.contains("Set/export"));
let settings = crate::config::read_settings(&app.config.paths).unwrap();
let provider = settings.custom_providers.get("local-llama").unwrap();
assert_eq!(provider.api_key_env_var, None);
}
#[test]
fn tui_duplicate_custom_provider_setup_requires_confirmation_and_cancel_preserves_record() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let mut ui_state = state::MissionControlState::default();
crate::login::configure_custom_provider(
&app.config.paths,
"local-ai",
"Old Local AI",
"http://localhost:8080/v1",
"OLD_LOCAL_AI_KEY",
)
.unwrap();
app.start_login(crate::login::CUSTOM_PROVIDER_LOGIN_ID, &mut ui_state);
ui_state
.modals
.custom_provider_setup
.as_mut()
.unwrap()
.label = "Local AI".to_string();
app.submit_custom_provider_setup_step(&mut ui_state);
ui_state
.modals
.custom_provider_setup
.as_mut()
.unwrap()
.label = "New Local AI".to_string();
ui_state
.modals
.custom_provider_setup
.as_mut()
.unwrap()
.base_url = "http://localhost:9090/v1".to_string();
app.submit_custom_provider_setup_step(&mut ui_state);
ui_state
.modals
.custom_provider_setup
.as_mut()
.unwrap()
.api_key_env_var = "NEW_LOCAL_AI_KEY".to_string();
app.submit_custom_provider_setup_step(&mut ui_state);
let confirmation = ui_state
.modals
.custom_provider_replacement_confirmation
.as_ref()
.expect("duplicate custom provider should require replacement confirmation");
assert_eq!(confirmation.provider_id, "local-ai");
assert_eq!(confirmation.existing_label, "Old Local AI");
assert_eq!(confirmation.label, "New Local AI");
assert!(ui_state.modals.custom_provider_setup.is_some());
assert!(ui_state.status.contains("already exists"));
let settings = crate::config::read_settings(&app.config.paths).unwrap();
let provider = settings.custom_providers.get("local-ai").unwrap();
assert_eq!(provider.label, "Old Local AI");
assert_eq!(provider.base_url, "http://localhost:8080/v1");
assert_eq!(
provider.api_key_env_var.as_deref(),
Some("OLD_LOCAL_AI_KEY")
);
ui_state.close_custom_provider_replacement_confirmation();
ui_state.clear_custom_provider_setup();
ui_state.status =
"custom provider replacement cancelled; existing provider unchanged".to_string();
let settings = crate::config::read_settings(&app.config.paths).unwrap();
let provider = settings.custom_providers.get("local-ai").unwrap();
assert_eq!(provider.label, "Old Local AI");
assert_eq!(provider.base_url, "http://localhost:8080/v1");
assert_eq!(
provider.api_key_env_var.as_deref(),
Some("OLD_LOCAL_AI_KEY")
);
assert!(
ui_state
.modals
.custom_provider_replacement_confirmation
.is_none()
);
assert!(ui_state.modals.custom_provider_setup.is_none());
}
#[test]
fn tui_duplicate_custom_provider_setup_confirm_replaces_record() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let mut ui_state = state::MissionControlState::default();
crate::login::configure_custom_provider(
&app.config.paths,
"local-ai",
"Old Local AI",
"http://localhost:8080/v1",
"OLD_LOCAL_AI_KEY",
)
.unwrap();
app.start_login(crate::login::CUSTOM_PROVIDER_LOGIN_ID, &mut ui_state);
ui_state
.modals
.custom_provider_setup
.as_mut()
.unwrap()
.label = "Local AI".to_string();
app.submit_custom_provider_setup_step(&mut ui_state);
ui_state
.modals
.custom_provider_setup
.as_mut()
.unwrap()
.label = "New Local AI".to_string();
ui_state
.modals
.custom_provider_setup
.as_mut()
.unwrap()
.base_url = "http://localhost:9090/v1".to_string();
app.submit_custom_provider_setup_step(&mut ui_state);
ui_state
.modals
.custom_provider_setup
.as_mut()
.unwrap()
.api_key_env_var = "NEW_LOCAL_AI_KEY".to_string();
app.submit_custom_provider_setup_step(&mut ui_state);
app.confirm_custom_provider_replacement(&mut ui_state);
assert!(
ui_state
.modals
.custom_provider_replacement_confirmation
.is_none()
);
assert!(ui_state.modals.custom_provider_setup.is_none());
let settings = crate::config::read_settings(&app.config.paths).unwrap();
let provider = settings.custom_providers.get("local-ai").unwrap();
assert_eq!(provider.label, "New Local AI");
assert_eq!(provider.base_url, "http://localhost:9090/v1");
assert_eq!(
provider.api_key_env_var.as_deref(),
Some("NEW_LOCAL_AI_KEY")
);
}
#[test]
fn tui_active_custom_provider_reconfiguration_refreshes_runtime() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let provider_id = "local-ai";
let model = "model-a";
let custom = crate::config::CustomProviderConfig {
label: "Old Local AI".to_string(),
base_url: "http://localhost:8080/v1".to_string(),
api_key_env_var: None,
models_dev_provider: Some("local-models".to_string()),
use_responses_endpoint: true,
supports_text_verbosity: false,
reasoning_protocol: crate::config::CustomReasoningProtocol::AnthropicLike,
extra_models: vec!["extra-model".to_string()],
};
crate::config::write_settings(
&app.config.paths,
&crate::config::Settings {
custom_providers: std::collections::BTreeMap::from([(
provider_id.to_string(),
custom.clone(),
)]),
selected_model: crate::config::SelectedModelSettings {
provider: Some(provider_id.to_string()),
model: Some(model.to_string()),
..Default::default()
},
..Default::default()
},
)
.unwrap();
let mut catalog_entry = crate::model_catalog::ModelCatalogEntry::new(provider_id, model);
catalog_entry.supports_reasoning = Some(true);
catalog_entry.reasoning_efforts = Some(vec![
crate::thinking::ThinkingLevel::Default,
crate::thinking::ThinkingLevel::High,
crate::thinking::ThinkingLevel::Max,
]);
crate::model_catalog::write_catalog_cache_for_configured_provider(
&app.config.paths,
provider_id,
&[catalog_entry],
)
.unwrap();
app.config.provider = Some(provider_id.to_string());
app.config.model = Some(model.to_string());
app.config.custom_providers =
std::collections::BTreeMap::from([(provider_id.to_string(), custom)]);
app.config.auth = Some(crate::config::ProviderCredential::NoAuth);
app.state.config = Some(app.config.clone());
app.state.model = model.to_string();
app.state.auth_state = crate::config::AuthState::Ready {
provider: provider_id.to_string(),
credential: crate::config::ProviderCredential::NoAuth,
};
let mut ui_state = state::MissionControlState {
provider: provider_id.to_string(),
model: model.to_string(),
provider_ready: true,
..Default::default()
};
app.start_login(crate::login::CUSTOM_PROVIDER_LOGIN_ID, &mut ui_state);
ui_state
.modals
.custom_provider_setup
.as_mut()
.unwrap()
.label = "Local AI".to_string();
app.submit_custom_provider_setup_step(&mut ui_state);
ui_state
.modals
.custom_provider_setup
.as_mut()
.unwrap()
.base_url = "http://localhost:9090/v1".to_string();
app.submit_custom_provider_setup_step(&mut ui_state);
ui_state
.modals
.custom_provider_setup
.as_mut()
.unwrap()
.api_key_env_var
.clear();
app.submit_custom_provider_setup_step(&mut ui_state);
app.confirm_custom_provider_replacement(&mut ui_state);
let saved = crate::config::read_settings(&app.config.paths).unwrap();
let saved_provider = saved.custom_providers.get(provider_id).unwrap();
assert_eq!(saved_provider.base_url, "http://localhost:9090/v1");
assert_eq!(
saved_provider.models_dev_provider.as_deref(),
Some("local-models")
);
assert!(saved_provider.use_responses_endpoint);
assert_eq!(
saved_provider.reasoning_protocol,
crate::config::CustomReasoningProtocol::AnthropicLike
);
assert_eq!(saved_provider.extra_models, vec!["extra-model"]);
assert_eq!(
app.config.custom_providers.get(provider_id),
Some(saved_provider)
);
assert_eq!(
app.state
.config
.as_ref()
.unwrap()
.custom_providers
.get(provider_id),
Some(saved_provider)
);
assert!(ui_state.provider_ready);
assert_eq!(ui_state.provider, provider_id);
assert_eq!(ui_state.model, model);
for level in [
crate::thinking::ThinkingLevel::Default,
crate::thinking::ThinkingLevel::High,
crate::thinking::ThinkingLevel::Max,
] {
assert!(
ui_state.thinking_levels.contains(&level),
"missing {level:?}"
);
}
}
#[test]
fn tui_direct_model_switch_preserves_provider_when_active_provider_is_codex() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
app.state.auth_state = crate::config::AuthState::Missing {
provider: crate::providers::OPENAI_CODEX_PROVIDER.to_string(),
};
crate::config::write_settings(
&app.config.paths,
&crate::config::Settings {
selected_model: crate::config::SelectedModelSettings {
provider: Some(crate::providers::OPENAI_CODEX_PROVIDER.to_string()),
model: Some("gpt-old".to_string()),
..Default::default()
},
..Default::default()
},
)
.unwrap();
let mut entry = crate::model_catalog::ModelCatalogEntry::new_codex("gpt-next");
entry.context_window = Some(196_000);
crate::model_catalog::write_catalog_cache(
&app.config.paths,
crate::providers::OPENAI_CODEX_PROVIDER,
&[entry],
)
.unwrap();
let mut ui_state = state::MissionControlState {
provider: crate::providers::OPENAI_CODEX_PROVIDER.to_string(),
model: "gpt-old".to_string(),
context_usage: Some(state::ContextUsageState {
current_tokens: 12_345,
max_tokens: 128_000,
reasoning_tokens: None,
..state::ContextUsageState::default()
}),
..Default::default()
};
app.select_model("openai-codex/gpt-next", &mut ui_state);
assert_eq!(app.state.model, "gpt-next");
assert_eq!(ui_state.provider, crate::providers::OPENAI_CODEX_PROVIDER);
assert_eq!(ui_state.model, "gpt-next");
assert_eq!(
ui_state.context_usage,
Some(state::ContextUsageState {
current_tokens: 12_345,
max_tokens: 196_000,
reasoning_tokens: None,
..state::ContextUsageState::default()
})
);
let saved = crate::config::read_settings(&app.config.paths).unwrap();
assert_eq!(
saved.selected_model.provider.as_deref(),
Some(crate::providers::OPENAI_CODEX_PROVIDER)
);
assert_eq!(saved.selected_model.model.as_deref(), Some("gpt-next"));
}
#[test]
fn tui_direct_setmodel_rejects_disabled_model_without_changing_selection() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
crate::model_catalog::write_catalog_cache(
&app.config.paths,
crate::providers::OPENAI_CODEX_PROVIDER,
&[crate::model_catalog::ModelCatalogEntry::new_codex(
"gpt-next",
)],
)
.unwrap();
crate::config::set_model_disabled_for_scope(
&app.config.paths,
crate::config::SettingsScope::Global,
"openai-codex/gpt-next",
true,
)
.unwrap();
let mut ui_state = state::MissionControlState {
provider: crate::providers::OPENAI_CODEX_PROVIDER.to_string(),
model: "gpt-old".to_string(),
..Default::default()
};
app.submit(
"/setmodel openai-codex/gpt-next".to_string(),
&mut ui_state,
&receiver,
test_area(),
);
assert_eq!(
ui_state.status,
"cannot select disabled model: openai-codex/gpt-next"
);
assert_eq!(ui_state.model, "gpt-old");
assert_ne!(app.state.model, "gpt-next");
}
#[test]
fn model_picker_excludes_disabled_models() {
let mut ui_state = state::MissionControlState {
provider: crate::providers::OPENAI_CODEX_PROVIDER.to_string(),
model: "gpt-old".to_string(),
..Default::default()
};
ui_state.open_model_picker(
vec![
crate::model_catalog::ModelCatalogEntry::new_codex("gpt-disabled"),
crate::model_catalog::ModelCatalogEntry::new_codex("gpt-enabled"),
],
std::collections::BTreeSet::from(["openai-codex/gpt-disabled".to_string()]),
None,
5,
);
let picker = ui_state.modals.model_picker.as_ref().unwrap();
let ids = picker
.entries
.iter()
.map(|entry| entry.id.as_str())
.collect::<Vec<_>>();
assert_eq!(ids, vec!["openai-codex/gpt-enabled"]);
assert_eq!(
ui_state.selected_model_picker_id().as_deref(),
Some("openai-codex/gpt-enabled")
);
}
#[test]
fn tui_direct_model_switch_uses_cached_custom_reasoning_metadata() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let mut custom_providers = std::collections::BTreeMap::new();
custom_providers.insert(
"local-ai".to_string(),
crate::config::CustomProviderConfig {
label: "Local AI".to_string(),
base_url: "http://localhost:11434/v1".to_string(),
api_key_env_var: None,
models_dev_provider: None,
reasoning_protocol: crate::config::CustomReasoningProtocol::default(),
use_responses_endpoint: false,
supports_text_verbosity: false,
extra_models: Vec::new(),
},
);
crate::config::write_settings(
&app.config.paths,
&crate::config::Settings {
selected_model: crate::config::SelectedModelSettings {
provider: Some("local-ai".to_string()),
model: Some("old-model".to_string()),
thinking_level: Some(crate::thinking::ThinkingLevel::High),
..Default::default()
},
custom_providers: custom_providers.clone(),
..Default::default()
},
)
.unwrap();
app.config.custom_providers = custom_providers;
app.config.thinking_level = crate::thinking::ThinkingLevel::High;
let mut supported = crate::model_catalog::ModelCatalogEntry::new("local-ai", "gpt-next");
supported.reasoning_efforts = Some(crate::thinking::ThinkingLevel::EFFORT_GENERIC.to_vec());
let mut unsupported = crate::model_catalog::ModelCatalogEntry::new("local-ai", "plain-next");
unsupported.reasoning_efforts = Some(vec![crate::thinking::ThinkingLevel::Default]);
crate::model_catalog::write_catalog_cache_for_configured_provider(
&app.config.paths,
"local-ai",
&[supported, unsupported],
)
.unwrap();
let mut ui_state = state::MissionControlState {
provider: "local-ai".to_string(),
model: "old-model".to_string(),
thinking_levels: vec![crate::thinking::ThinkingLevel::Default],
..Default::default()
};
app.select_model("local-ai/gpt-next", &mut ui_state);
assert_eq!(ui_state.provider, "local-ai");
assert_eq!(ui_state.model, "gpt-next");
assert_eq!(
ui_state.thinking_levels,
crate::thinking::ThinkingLevel::EFFORT_GENERIC.to_vec()
);
assert_eq!(
ui_state.effective_thinking_level,
crate::thinking::ThinkingLevel::High
);
app.select_model("local-ai/plain-next", &mut ui_state);
assert_eq!(
ui_state.thinking_levels,
vec![crate::thinking::ThinkingLevel::Default]
);
assert_eq!(
ui_state.effective_thinking_level,
crate::thinking::ThinkingLevel::Default
);
}
#[test]
fn tui_direct_model_switch_uses_configured_context_fallback_when_catalog_lacks_metadata() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
app.state.auth_state = crate::config::AuthState::Missing {
provider: crate::providers::OPENAI_CODEX_PROVIDER.to_string(),
};
crate::config::write_settings(
&app.config.paths,
&crate::config::Settings {
selected_model: crate::config::SelectedModelSettings {
provider: Some(crate::providers::OPENAI_CODEX_PROVIDER.to_string()),
model: Some("gpt-old".to_string()),
..Default::default()
},
context: Some(crate::context::ContextBudget {
max_tokens: 64_000,
..Default::default()
}),
..Default::default()
},
)
.unwrap();
crate::model_catalog::write_catalog_cache(
&app.config.paths,
crate::providers::OPENAI_CODEX_PROVIDER,
&[crate::model_catalog::ModelCatalogEntry::new_codex(
"gpt-next",
)],
)
.unwrap();
let mut ui_state = state::MissionControlState {
provider: crate::providers::OPENAI_CODEX_PROVIDER.to_string(),
model: "gpt-old".to_string(),
context_usage: Some(state::ContextUsageState {
current_tokens: 12_345,
max_tokens: 128_000,
reasoning_tokens: None,
..state::ContextUsageState::default()
}),
..Default::default()
};
app.select_model("openai-codex/gpt-next", &mut ui_state);
assert_eq!(
ui_state.context_usage,
Some(state::ContextUsageState {
current_tokens: 12_345,
max_tokens: 64_000,
reasoning_tokens: None,
..state::ContextUsageState::default()
})
);
}
#[test]
fn submit_unknown_skill_slash_command_reports_error_and_creates_no_worker() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let mut ui_state = state::MissionControlState::default();
app.submit(
"/skill:review".to_string(),
&mut ui_state,
&receiver,
test_area(),
);
assert!(matches!(
receiver.try_recv().unwrap(),
TuiEvent::Error(message) if message == "unknown command: /skill:review"
));
assert!(receiver.try_recv().is_err());
assert!(app.worker.is_none());
assert!(!app.active_run);
}
#[test]
fn submit_decision_characterizes_empty_active_and_normal_prompts() {
assert_eq!(submit_decision(false, " \t"), SubmitDecision::IgnoreEmpty);
assert_eq!(
submit_decision(true, "next prompt"),
SubmitDecision::QueueSteering
);
assert_eq!(
submit_decision(false, "next prompt"),
SubmitDecision::Accept
);
}
#[test]
fn submit_decision_queues_while_run_active() {
assert_eq!(submit_decision(true, "next"), SubmitDecision::QueueSteering);
}
#[test]
fn submit_decision_accepts_after_run_finished() {
let (sender, receiver) = bounded::<TuiEvent>(2);
sender
.send(TuiEvent::RunFinished { final_event: None })
.unwrap();
let mut state = state::MissionControlState::default();
assert!(drain_tui_events(&receiver, &mut state).run_finished);
assert_eq!(submit_decision(false, "next"), SubmitDecision::Accept);
}
#[test]
fn worker_handle_is_joined_after_run_finished() {
let temp = tempfile::TempDir::new().unwrap();
let paths = crate::config::McPaths {
root: temp.path().join("mc"),
cache: temp.path().join("mc/cache"),
state: temp.path().join("mc/state"),
sessions: temp.path().join("mc/sessions"),
checkpoints: temp.path().join("mc/checkpoints"),
skills: temp.path().join("mc/skills"),
prompts: temp.path().join("mc/prompts"),
subagents: temp.path().join("mc/subagents"),
primary_agents: temp.path().join("mc/primary-agents"),
user_agents: temp.path().join("mc/agents"),
settings_file: temp.path().join("mc/settings.json"),
project_settings_file: temp.path().join("project/.magi-code/settings.json"),
local_settings_file: None,
auth_file: temp.path().join("mc/auth.json"),
};
let (sender, _receiver) = bounded::<TuiEvent>(2);
let mut app = MissionControlApp::new(
TuiSessionConfig {
config: EffectiveConfig {
provider: Some("openai".to_string()),
model: Some("test-model".to_string()),
no_color: false,
file_autocomplete_respects_gitignore: true,
custom_providers: std::collections::BTreeMap::new(),
thinking_level: crate::thinking::ThinkingLevel::Default,
api_key: None,
auth: None,
paths,
},
instructions: Vec::new(),
discovered_skills: SkillDiscovery::default(),
skills: SkillDiscovery::default(),
commands: CommandRegistry::mvp(),
manager: SessionManager::new(temp.path().join("sessions")),
active_session: None,
cwd: temp.path().to_path_buf(),
},
sender,
);
app.active_run = true;
app.worker = Some(WorkerState {
handle: thread::spawn(|| {}),
cancel: Arc::new(AtomicBool::new(false)),
login_manual: None,
outcome: Arc::new(WorkerOutcomeState::default()),
outcome_reconciled: false,
steering: crate::agent::steering::AgentSteering::new(),
accepts_steering: false,
});
while !app.worker.as_ref().unwrap().handle.is_finished() {
thread::yield_now();
}
let mut ui_state = state::MissionControlState::default();
app.join_completed_worker(&mut ui_state);
assert!(!app.active_run);
assert!(app.worker.is_none());
}
#[test]
fn submit_decision_queues_concurrent_prompt_but_keeps_empty_noop() {
assert_eq!(submit_decision(false, "hello"), SubmitDecision::Accept);
assert_eq!(submit_decision(false, " "), SubmitDecision::IgnoreEmpty);
assert_eq!(submit_decision(true, "next"), SubmitDecision::QueueSteering);
}
#[test]
fn logout_custom_provider_opens_confirmation_and_removes_metadata() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
crate::login::configure_custom_provider(
&app.config.paths,
"local-ai",
"Local AI",
"http://localhost:8080/v1",
"LOCAL_AI_KEY",
)
.unwrap();
let mut ui_state = state::MissionControlState::default();
app.logout_provider("local-ai", &mut ui_state);
assert!(matches!(
ui_state.modals.logout_confirmation.as_ref(),
Some(confirmation) if confirmation.provider_id == "local-ai"
));
ui_state.close_logout_confirmation();
app.remove_logout_provider("local-ai", &mut ui_state);
assert!(ui_state.status.contains("removed custom provider local-ai"));
assert!(
!crate::config::read_settings(&app.config.paths)
.unwrap()
.custom_providers
.contains_key("local-ai")
);
assert!(app.worker.is_none());
assert!(!app.active_run);
}
#[test]
fn logout_direct_provider_opens_confirmation_without_worker_or_prompt() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
app.config.provider = Some(crate::providers::OPENAI_CODEX_PROVIDER.to_string());
app.state.config = Some(app.config.clone());
let mut auth = crate::config::Auth::default();
auth.providers.insert(
crate::providers::OPENAI_CODEX_PROVIDER.to_string(),
crate::config::AuthProviderRecord::OAuth {
access: "access".into(),
refresh: Some("refresh".into()),
expires: None,
account_id: Some("acct".into()),
},
);
crate::config::write_auth(&app.config.paths, &auth).unwrap();
let mut ui_state = state::MissionControlState::default();
app.submit(
"/logout openai-codex".to_string(),
&mut ui_state,
&receiver,
test_area(),
);
assert!(ui_state.modals.logout_confirmation.is_some());
assert!(app.worker.is_none());
assert!(!app.active_run);
assert!(receiver.try_recv().is_err());
assert!(
crate::config::read_auth(&app.config.paths)
.unwrap()
.providers
.contains_key(crate::providers::OPENAI_CODEX_PROVIDER)
);
}
#[test]
fn logout_confirmation_removes_auth_and_clears_runtime_state() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = ready_codex_app(&temp, sender);
let mut ui_state = state::MissionControlState {
provider_ready: true,
..Default::default()
};
app.remove_logout_provider(crate::providers::OPENAI_CODEX_PROVIDER, &mut ui_state);
assert!(
!crate::config::read_auth(&app.config.paths)
.unwrap()
.providers
.contains_key(crate::providers::OPENAI_CODEX_PROVIDER)
);
assert!(!app.state.auth_state.is_ready());
assert!(!ui_state.provider_ready);
assert!(app.config.auth.is_none());
assert!(app.state.config.as_ref().unwrap().auth.is_none());
}
#[test]
fn logout_picker_escape_preserves_auth_runtime_state_and_workers() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let app = ready_codex_app(&temp, sender);
let mut ui_state = state::MissionControlState::default();
ui_state.open_logout_picker(
vec![state::LoginProviderEntry {
id: crate::providers::OPENAI_CODEX_PROVIDER.to_string(),
label: "OpenAI Codex".to_string(),
description: "codex".to_string(),
status: "configured".to_string(),
}],
None,
input::DEFAULT_PICKER_VISIBLE_ROWS as usize,
);
let action = input::handle_key(
crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
),
&mut ui_state,
);
assert!(matches!(action, input::InputAction::None));
assert!(ui_state.modals.login_picker.is_none());
assert!(
crate::config::read_auth(&app.config.paths)
.unwrap()
.providers
.contains_key(crate::providers::OPENAI_CODEX_PROVIDER)
);
assert!(app.state.auth_state.is_ready());
assert!(app.config.auth.is_some());
assert!(app.worker.is_none());
assert!(!app.active_run);
assert!(receiver.try_recv().is_err());
}
#[test]
fn logout_bad_provider_and_auth_read_failures_do_not_start_workers_or_submit_prompts() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = ready_codex_app(&temp, sender);
let mut ui_state = state::MissionControlState::default();
app.submit(
"/logout bad-provider".to_string(),
&mut ui_state,
&receiver,
test_area(),
);
assert!(app.worker.is_none());
assert!(!app.active_run);
assert!(matches!(
receiver.try_recv().unwrap(),
TuiEvent::Error(message) if message.contains("unsupported logout provider 'bad-provider'")
));
assert!(
crate::config::read_auth(&app.config.paths)
.unwrap()
.providers
.contains_key(crate::providers::OPENAI_CODEX_PROVIDER)
);
std::fs::write(&app.config.paths.auth_file, b"not json").unwrap();
app.submit(
"/logout openai-codex".to_string(),
&mut ui_state,
&receiver,
test_area(),
);
assert!(app.worker.is_none());
assert!(!app.active_run);
assert!(matches!(
receiver.try_recv().unwrap(),
TuiEvent::Error(message) if message.contains("expected ident") || message.contains("key must be a string")
));
assert!(ui_state.modals.logout_confirmation.is_none());
}
#[cfg(unix)]
#[test]
fn logout_unsafe_auth_file_failure_does_not_start_worker_or_submit_prompt() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = ready_codex_app(&temp, sender);
let mut permissions = std::fs::metadata(&app.config.paths.auth_file)
.unwrap()
.permissions();
permissions.set_mode(0o644);
std::fs::set_permissions(&app.config.paths.auth_file, permissions).unwrap();
let mut ui_state = state::MissionControlState::default();
app.submit(
"/logout openai-codex".to_string(),
&mut ui_state,
&receiver,
test_area(),
);
assert!(app.worker.is_none());
assert!(!app.active_run);
assert!(matches!(
receiver.try_recv().unwrap(),
TuiEvent::Error(message) if message.contains("auth.json permissions must be private")
));
assert!(ui_state.modals.logout_confirmation.is_none());
}
#[test]
fn post_logout_prompt_requires_relogin_without_starting_provider_worker() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(8);
let mut app = ready_codex_app(&temp, sender);
let mut ui_state = state::MissionControlState {
provider_ready: true,
..Default::default()
};
app.remove_logout_provider(crate::providers::OPENAI_CODEX_PROVIDER, &mut ui_state);
app.submit("hello".to_string(), &mut ui_state, &receiver, test_area());
assert!(app.worker.is_none());
assert!(!app.active_run);
assert!(!ui_state.provider_ready);
let message = match receiver.try_recv().unwrap() {
TuiEvent::Error(message) => message,
other => panic!("expected provider configuration error, got {other:?}"),
};
assert!(
message.contains("openai-codex credential refresh failed")
&& message.contains("run /login openai-codex"),
"unexpected message: {message}"
);
}
#[test]
fn post_logout_model_discovery_requires_relogin() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let mut app = ready_codex_app(&temp, sender);
let mut ui_state = state::MissionControlState {
provider_ready: true,
..Default::default()
};
app.remove_logout_provider(crate::providers::OPENAI_CODEX_PROVIDER, &mut ui_state);
app.open_model_picker(&mut ui_state, test_area());
assert!(ui_state.modals.model_picker.is_none());
assert!(ui_state.status.contains("loading model catalogs"));
}
#[test]
fn finished_worker_is_joined_without_run_finished_event() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(2);
let mut app = test_app(&temp, sender);
app.active_run = true;
app.worker = Some(WorkerState {
handle: thread::spawn(|| {}),
cancel: Arc::new(AtomicBool::new(false)),
login_manual: None,
outcome: Arc::new(WorkerOutcomeState::default()),
outcome_reconciled: false,
steering: crate::agent::steering::AgentSteering::new(),
accepts_steering: false,
});
while !app.worker.as_ref().unwrap().handle.is_finished() {
thread::yield_now();
}
let mut ui_state = state::MissionControlState::default();
assert!(app.finish_worker_if_ready(&mut ui_state));
assert!(!app.active_run);
assert!(app.worker.is_none());
}
#[test]
fn cancel_finished_worker_joins_and_clears_state() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(2);
let mut app = test_app(&temp, sender);
app.active_run = true;
let cancel = Arc::new(AtomicBool::new(false));
app.worker = Some(WorkerState {
handle: thread::spawn(|| {}),
cancel: cancel.clone(),
login_manual: None,
outcome: Arc::new(WorkerOutcomeState::default()),
outcome_reconciled: false,
steering: crate::agent::steering::AgentSteering::new(),
accepts_steering: false,
});
while !app.worker.as_ref().unwrap().handle.is_finished() {
thread::yield_now();
}
let mut ui_state = state::MissionControlState::default();
let status = app.cancel_active_worker(&mut ui_state);
assert!(status.is_none());
assert!(cancel.load(Ordering::SeqCst));
assert!(!app.active_run);
assert!(app.worker.is_none());
}
#[test]
fn cancel_running_worker_reconciles_on_subsequent_ready_check() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(2);
let mut app = test_app(&temp, sender);
app.active_run = true;
let cancel = Arc::new(AtomicBool::new(false));
let worker_cancel = cancel.clone();
app.worker = Some(WorkerState {
handle: thread::spawn(move || {
while !worker_cancel.load(Ordering::SeqCst) {
thread::sleep(Duration::from_millis(1));
}
thread::sleep(Duration::from_millis(25));
}),
cancel: cancel.clone(),
login_manual: None,
outcome: Arc::new(WorkerOutcomeState::default()),
outcome_reconciled: false,
steering: crate::agent::steering::AgentSteering::new(),
accepts_steering: false,
});
let mut ui_state = state::MissionControlState::default();
let status = app.cancel_active_worker(&mut ui_state);
assert!(status.is_some());
assert!(cancel.load(Ordering::SeqCst));
assert!(!app.active_run);
assert!(app.worker.is_some());
while !app.worker.as_ref().unwrap().handle.is_finished() {
thread::yield_now();
}
assert!(app.finish_worker_if_ready(&mut ui_state));
assert!(app.worker.is_none());
}
#[test]
fn cancel_blocked_worker_reports_blocking_call_limitation_without_joining() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(2);
let mut app = test_app(&temp, sender);
app.active_run = true;
let cancel = Arc::new(AtomicBool::new(false));
let worker_cancel = cancel.clone();
app.worker = Some(WorkerState {
handle: thread::spawn(move || {
while !worker_cancel.load(Ordering::SeqCst) {
thread::sleep(Duration::from_millis(1));
}
thread::sleep(CANCEL_JOIN_GRACE_PERIOD + Duration::from_millis(25));
}),
cancel: cancel.clone(),
login_manual: None,
outcome: Arc::new(WorkerOutcomeState::default()),
outcome_reconciled: false,
steering: crate::agent::steering::AgentSteering::new(),
accepts_steering: false,
});
let mut ui_state = state::MissionControlState::default();
let status = app.cancel_active_worker(&mut ui_state);
assert_eq!(
status.as_deref(),
Some(
"cancellation requested; provider worker is still shutting down its current blocking call"
)
);
assert!(cancel.load(Ordering::SeqCst));
assert!(!app.active_run);
assert!(app.worker.is_some());
while !app.worker.as_ref().unwrap().handle.is_finished() {
thread::yield_now();
}
assert!(app.finish_worker_if_ready(&mut ui_state));
}
#[test]
fn worker_outcome_reconciles_after_saturated_completion_channel() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(1);
sender.send(TuiEvent::Error("fill".to_string())).unwrap();
let outcome = Arc::new(WorkerOutcomeState::default());
let worker_outcome = Arc::clone(&outcome);
let worker_sender = sender.clone();
let handle = thread::spawn(move || {
send_completion(
&worker_sender,
&worker_outcome,
Some(WorkerFinalEvent::Done),
);
});
while !handle.is_finished() {
thread::yield_now();
}
let mut app = test_app(&temp, sender);
app.active_run = true;
app.worker = Some(WorkerState {
handle,
cancel: Arc::new(AtomicBool::new(false)),
login_manual: None,
outcome,
outcome_reconciled: false,
steering: crate::agent::steering::AgentSteering::new(),
accepts_steering: false,
});
let mut ui_state = state::MissionControlState::default();
ui_state.start_running_prompt("inspect".to_string());
ui_state.assistant_streaming = true;
assert!(app.finish_worker_if_ready(&mut ui_state));
assert_eq!(ui_state.status, "run complete");
assert!(ui_state.running_prompt.is_none());
assert!(!ui_state.assistant_streaming);
assert!(app.worker.is_none());
assert!(matches!(receiver.recv().unwrap(), TuiEvent::Error(message) if message == "fill"));
assert!(receiver.try_recv().is_err());
}
#[test]
fn completion_send_attempts_run_finished_after_saturated_final_status() {
let (sender, receiver) = bounded::<TuiEvent>(1);
sender.send(TuiEvent::Error("fill".to_string())).unwrap();
let outcome = WorkerOutcomeState::default();
send_completion(&sender, &outcome, Some(WorkerFinalEvent::Done));
assert!(matches!(receiver.recv().unwrap(), TuiEvent::Error(message) if message == "fill"));
assert!(receiver.try_recv().is_err());
}
#[test]
fn submit_skills_opens_modal_and_toggle_persists_for_fresh_load() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(8);
let mut app = test_app(&temp, sender);
let skill_path = temp.path().join("skills/review/SKILL.md");
std::fs::create_dir_all(skill_path.parent().unwrap()).unwrap();
std::fs::write(&skill_path, "# Review\n").unwrap();
let skill = crate::skills::load_skill(&skill_path).unwrap();
app.discovered_skills
.skills
.insert("review".to_string(), skill.clone());
app.skills.skills.insert("review".to_string(), skill);
let mut ui_state = state::MissionControlState::default();
app.submit("/skills".to_string(), &mut ui_state, &receiver, test_area());
assert!(ui_state.skills_modal_visible());
assert!(ui_state.modals.skills_modal.as_ref().unwrap().rows[0].enabled);
app.set_skill_enabled("review", false, &mut ui_state);
assert!(!app.skills.skills.contains_key("review"));
assert_eq!(
crate::config::disabled_skill_names(&app.config.paths).unwrap(),
std::collections::BTreeSet::from(["review".to_string()])
);
let mut fresh_state = state::MissionControlState::default();
app.open_skills_modal(&mut fresh_state, test_area());
assert!(!fresh_state.modals.skills_modal.as_ref().unwrap().rows[0].enabled);
}
#[test]
fn submit_subagents_opens_modal_and_toggle_is_session_scoped() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(8);
let mut app = test_app(&temp, sender);
app.subagent_profile_discovery.profiles.insert(
"review".to_string(),
crate::subagents::profiles::SubagentProfile {
id: "review".to_string(),
name: "review".to_string(),
description: "Code review".to_string(),
model: None,
reasoning: None,
output_schema: None,
path: temp.path().join("review.md"),
prompt: "Review code".to_string(),
},
);
let mut ui_state = state::MissionControlState::default();
app.submit(
"/subagents".to_string(),
&mut ui_state,
&receiver,
test_area(),
);
assert!(ui_state.subagents_modal_visible());
assert!(ui_state.modals.subagents_modal.as_ref().unwrap().rows[0].enabled);
app.set_subagent_profile_enabled("review", false, &mut ui_state);
assert!(
app.disabled_subagent_profiles
.lock()
.unwrap()
.contains("review")
);
let mut fresh_state = state::MissionControlState::default();
app.open_subagents_modal(&mut fresh_state, test_area());
assert!(!fresh_state.modals.subagents_modal.as_ref().unwrap().rows[0].enabled);
}
#[test]
fn sessions_command_opens_modal_without_provider_submission() {
let temp = tempfile::TempDir::new().unwrap();
let manager = SessionManager::new(temp.path().join("sessions"));
let session = manager.open("saved-session").unwrap();
session
.append(&crate::sessions::SessionEvent::new(
"diagnostic",
session.id().to_string(),
temp.path().to_path_buf(),
serde_json::json!({}),
))
.unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let mut ui_state = state::MissionControlState::default();
app.submit(
"/sessions".to_string(),
&mut ui_state,
&receiver,
test_area(),
);
assert!(ui_state.session_picker_visible());
assert_eq!(
ui_state.modals.session_picker.as_ref().unwrap().rows.len(),
1
);
assert!(app.worker.is_none());
assert!(!app.active_run);
assert!(receiver.try_recv().is_err());
}
#[test]
fn sessions_command_rejects_arguments_without_provider_submission() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let mut ui_state = state::MissionControlState::default();
app.submit(
"/sessions extra".to_string(),
&mut ui_state,
&receiver,
test_area(),
);
assert_eq!(ui_state.status, "usage: /sessions");
assert!(!ui_state.session_picker_visible());
assert!(app.worker.is_none());
assert!(!app.active_run);
assert!(receiver.try_recv().is_err());
}
#[test]
fn sessions_command_rejects_while_run_active() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
app.active_run = true;
let mut ui_state = state::MissionControlState::default();
app.submit(
"/sessions".to_string(),
&mut ui_state,
&receiver,
test_area(),
);
assert!(matches!(
receiver.try_recv().unwrap(),
TuiEvent::Error(message) if message == "cannot switch sessions while a prompt is running"
));
}
#[test]
fn cancelling_session_picker_preserves_active_session() {
let temp = tempfile::TempDir::new().unwrap();
let manager = SessionManager::new(temp.path().join("sessions"));
let active = manager.open("active-session").unwrap();
let target = manager.open("target-session").unwrap();
active
.append(&crate::sessions::SessionEvent::new(
"diagnostic",
active.id().to_string(),
temp.path().to_path_buf(),
serde_json::json!({}),
))
.unwrap();
target
.append(&crate::sessions::SessionEvent::new(
"diagnostic",
target.id().to_string(),
temp.path().to_path_buf(),
serde_json::json!({}),
))
.unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(4);
let app = test_app_with_active_session(&temp, sender, Some(active.clone()));
let mut ui_state = state::MissionControlState::default();
ui_state.open_session_picker(
vec![state::SessionPickerRow {
id: target.id().to_string(),
label: target.id().to_string(),
is_current: false,
preview: None,
}],
1,
);
let action = input::handle_key(
crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
),
&mut ui_state,
);
assert_eq!(action, input::InputAction::None);
assert!(!ui_state.session_picker_visible());
assert_eq!(app.state.active_session_id(), Some(active.id()));
}
#[test]
fn selected_session_is_used_for_next_provider_run() {
let temp = tempfile::TempDir::new().unwrap();
let manager = SessionManager::new(temp.path().join("sessions"));
let active = manager.open("active-session").unwrap();
let target = manager.open("target-session").unwrap();
active
.append(&crate::sessions::SessionEvent::new(
"diagnostic",
active.id().to_string(),
temp.path().to_path_buf(),
serde_json::json!({"seed":"active"}),
))
.unwrap();
target
.append(&crate::sessions::SessionEvent::new(
"diagnostic",
target.id().to_string(),
temp.path().to_path_buf(),
serde_json::json!({"seed":"target"}),
))
.unwrap();
let active_before = active.read_events().unwrap().len();
let target_before = target.read_events().unwrap().len();
let (sender, receiver) = bounded::<TuiEvent>(8);
let mut app = test_app_with_active_session(&temp, sender.clone(), Some(active.clone()));
let mut ui_state = state::MissionControlState::default();
execute_switch_session_command(
&mut app.state,
&mut ui_state,
target.id(),
&receiver,
&sender,
)
.unwrap();
app.submit(
"next prompt".to_string(),
&mut ui_state,
&receiver,
test_area(),
);
assert_eq!(app.state.active_session_id(), Some(target.id()));
assert_eq!(active.read_events().unwrap().len(), active_before);
let target_events = target.read_events().unwrap();
assert_eq!(target_events.len(), target_before + 1);
assert_eq!(target_events.last().unwrap().session_id, target.id());
assert_eq!(target_events.last().unwrap().event_type, "diagnostic");
}
#[test]
fn bang_prompt_submit_starts_bash_worker_without_provider_auth() {
let temp = tempfile::TempDir::new().unwrap();
let manager = SessionManager::new(temp.path().join("sessions"));
let session = manager.create().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(32);
let mut app = test_app_with_active_session(&temp, sender, Some(session.clone()));
app.state.auth_state = crate::config::AuthState::Missing {
provider: crate::providers::OPENAI_CODEX_PROVIDER.to_string(),
};
let mut ui_state = state::MissionControlState::default();
let accepted = app.submit(
"!printf hi".to_string(),
&mut ui_state,
&receiver,
test_area(),
);
assert!(accepted);
assert_eq!(ui_state.status, "bash-mode: running command…");
assert!(app.active_run);
if let Some(worker) = app.worker.take() {
worker.handle.join().unwrap();
}
let result = drain_tui_events(&receiver, &mut ui_state);
assert!(result.run_finished);
assert!(
!ui_state
.transcript
.iter()
.any(|line| line.starts_with("you: ")),
"transcript: {:?}",
ui_state.transcript
);
assert!(
ui_state
.transcript
.iter()
.any(|line| line == "bash: printf hi"),
"transcript: {:?}",
ui_state.transcript
);
assert!(
ui_state
.transcript
.iter()
.any(|line| line.starts_with("tool: ✓ bash")),
"transcript: {:?}",
ui_state.transcript
);
assert!(
ui_state
.transcript
.iter()
.all(|line| !line.contains("provider not configured"))
);
let events = session.read_events().unwrap();
assert!(events.is_empty(), "events: {events:?}");
}