use super::*;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
#[derive(Default)]
struct Clipboard(String);
impl input::clipboard::ClipboardWriter for Clipboard {
fn copy(&mut self, text: &str) -> anyhow::Result<()> {
self.0 = text.into();
Ok(())
}
}
fn poll_until(
app: &mut MissionControlApp,
ui: &mut state::MissionControlState,
ready: impl Fn(&MissionControlApp) -> bool,
) {
let deadline = Instant::now() + Duration::from_secs(5);
while !ready(app) {
app.poll_side(ui);
assert!(Instant::now() < deadline, "side runtime did not settle");
thread::sleep(Duration::from_millis(1));
}
}
fn ready_app(temp: &tempfile::TempDir) -> (MissionControlApp, state::MissionControlState) {
let (events, _receiver) = bounded(1024);
let mut app = super::super::tests::test_app(temp, events);
app.side.enabled = true;
app.state.current_session = Some(
app.state
.session_manager
.create()
.unwrap()
.admit_standalone_writer()
.unwrap(),
);
let mut ui = state::MissionControlState::default();
app.open_side(&mut ui);
poll_until(&mut app, &mut ui, |app| {
app.side.conversation.is_some() && app.side.creation.is_none()
});
(app, ui)
}
fn key(
app: &mut MissionControlApp,
ui: &mut state::MissionControlState,
code: KeyCode,
modifiers: KeyModifiers,
) {
app.handle_side_input(
TerminalInputEvent::Event(Event::Key(KeyEvent::new(code, modifiers))),
ui,
ratatui::layout::Rect::new(0, 0, 100, 30),
&mut Clipboard::default(),
&[],
);
}
fn controlled_worker(events: Sender<TuiEvent>) -> (WorkerState, Sender<()>, Arc<AtomicBool>) {
let (release, wait) = bounded(1);
let cancel = Arc::new(AtomicBool::new(false));
let outcome = Arc::new(WorkerOutcomeState::default());
let worker_outcome = Arc::clone(&outcome);
let handle = thread::spawn(move || {
wait.recv().unwrap();
send_completion(&events, &worker_outcome, Some(WorkerFinalEvent::Done));
});
(
WorkerState {
handle,
cancel: Arc::clone(&cancel),
login_manual: None,
outcome,
outcome_reconciled: false,
shutdown_policy: WorkerShutdownPolicy::Cancel,
steering: crate::agent::steering::AgentSteering::new(),
accepts_steering: true,
},
release,
cancel,
)
}
#[test]
fn side_cancel_recalls_steering_before_canceling_worker() {
let temp = tempfile::TempDir::new().unwrap();
let (mut app, mut ui) = ready_app(&temp);
ui.set_prompt_text("primary draft", 13);
let side = app.side.conversation.as_mut().unwrap();
let (worker, release, cancel) = controlled_worker(side.events.clone());
side.steering = worker.steering.clone();
side.steering.try_enqueue("first".into()).unwrap();
side.steering.try_enqueue("second".into()).unwrap();
side.last_steering_pending_count = 2;
side.worker = Some(worker);
side.active_run = true;
let modal = ui.conversation_modal.as_mut().unwrap();
modal.state.set_prompt_text("draft", 5);
modal.state.pending_steering_count = 2;
key(&mut app, &mut ui, KeyCode::Char('c'), KeyModifiers::ALT);
let modal = ui.conversation_modal.as_ref().unwrap();
assert_eq!(modal.state.prompt_plain_text(), "first\nsecond\ndraft");
assert_eq!(modal.state.pending_steering_count, 0);
assert!(app.side.conversation.as_ref().unwrap().active_run);
assert!(!cancel.load(Ordering::SeqCst));
assert_eq!(ui.prompt_plain_text(), "primary draft");
key(&mut app, &mut ui, KeyCode::Char('c'), KeyModifiers::ALT);
assert!(cancel.load(Ordering::SeqCst));
assert!(!app.side.conversation.as_ref().unwrap().active_run);
release.send(()).unwrap();
app.cleanup_after_run().unwrap();
}
#[test]
fn side_nested_subagent_input_never_cancels_or_edits_parent_conversations() {
use crate::output::{
ActivityEvent, ActivityId, ActivityKind, ActivityMetadata, ActivityStatus,
};
let temp = tempfile::TempDir::new().unwrap();
let (mut app, mut ui) = ready_app(&temp);
ui.set_prompt_text("primary draft", 13);
let side = app.side.conversation.as_mut().unwrap();
let (worker, release, cancel) = controlled_worker(side.events.clone());
side.worker = Some(worker);
side.active_run = true;
let modal = ui.conversation_modal.as_mut().unwrap();
modal.state.set_prompt_text("side draft", 10);
let batch = ActivityId::new("run/outer/child/nested-batch");
let task = batch.child("nested-child");
for (id, parent_id, kind) in [
(batch.clone(), None, ActivityKind::SubagentBatch),
(
task.clone(),
Some(batch.clone()),
ActivityKind::SubagentTask,
),
] {
modal.state.apply_activity_event(ActivityEvent::Started {
id,
parent_id,
kind,
status: ActivityStatus::Running,
metadata: ActivityMetadata::new("nested child"),
});
}
assert!(modal.state.open_subagent_viewer(batch, task));
key(&mut app, &mut ui, KeyCode::Char('x'), KeyModifiers::NONE);
key(&mut app, &mut ui, KeyCode::Enter, KeyModifiers::NONE);
app.handle_side_input(
TerminalInputEvent::Event(Event::Paste("child paste".into())),
&mut ui,
ratatui::layout::Rect::new(0, 0, 100, 30),
&mut Clipboard::default(),
&[],
);
key(&mut app, &mut ui, KeyCode::Char('c'), KeyModifiers::ALT);
let modal = ui.conversation_modal.as_ref().unwrap();
assert_eq!(modal.state.prompt_plain_text(), "side draft");
assert_eq!(ui.prompt_plain_text(), "primary draft");
assert!(!modal.state.status.contains("Only /reset"));
assert!(!cancel.load(Ordering::SeqCst));
assert!(app.side.conversation.as_ref().unwrap().active_run);
key(&mut app, &mut ui, KeyCode::Esc, KeyModifiers::NONE);
assert!(ui.conversation_modal.as_ref().unwrap().visible);
key(&mut app, &mut ui, KeyCode::Char('c'), KeyModifiers::ALT);
assert!(cancel.load(Ordering::SeqCst));
release.send(()).unwrap();
app.cleanup_after_run().unwrap();
}
#[test]
fn side_escape_dismisses_autocomplete_before_closing_conversation() {
let temp = tempfile::TempDir::new().unwrap();
let (mut app, mut ui) = ready_app(&temp);
let modal = ui.conversation_modal.as_mut().unwrap();
modal.state.set_prompt_text("/r", 2);
modal
.state
.recompute_autocomplete(&side_autocomplete_candidates(&[]));
assert!(modal.state.autocomplete_visible());
key(&mut app, &mut ui, KeyCode::Esc, KeyModifiers::NONE);
let modal = ui.conversation_modal.as_ref().unwrap();
assert!(modal.visible);
assert!(!modal.state.autocomplete_visible());
assert_eq!(modal.state.prompt_plain_text(), "/r");
key(&mut app, &mut ui, KeyCode::Esc, KeyModifiers::NONE);
assert!(!ui.conversation_modal.as_ref().unwrap().visible);
}
#[test]
fn update_waits_for_hidden_side_worker_and_reset() {
let temp = tempfile::TempDir::new().unwrap();
let (mut app, mut ui) = ready_app(&temp);
key(&mut app, &mut ui, KeyCode::Esc, KeyModifiers::NONE);
let side = app.side.conversation.as_mut().unwrap();
let (worker, release, cancel) = controlled_worker(side.events.clone());
ui.conversation_modal
.as_mut()
.unwrap()
.state
.active_worker_id = Some(worker.outcome.worker_id());
side.worker = Some(worker);
side.active_run = true;
assert!(!app.request_update(None, &mut ui));
assert!(app.update_restart.is_none());
assert!(!cancel.load(Ordering::SeqCst));
release.send(()).unwrap();
poll_until(&mut app, &mut ui, |app| !app.side.is_pending());
app.side.reset_requested = true;
assert!(!app.request_update(None, &mut ui));
assert!(app.update_restart.is_none());
app.side.reset_requested = false;
assert!(app.request_update(None, &mut ui));
}
#[test]
fn update_waits_for_side_creation() {
let temp = tempfile::TempDir::new().unwrap();
let (mut app, mut ui) = ready_app(&temp);
let mut modal = ui.conversation_modal.take().unwrap();
app.submit_side("/reset".into(), &mut modal);
ui.conversation_modal = Some(modal);
app.poll_side(&mut ui);
assert!(app.side.creation.is_some());
assert!(!app.request_update(None, &mut ui));
assert!(app.update_restart.is_none());
poll_until(&mut app, &mut ui, |app| !app.side.is_pending());
}
#[test]
fn side_is_disabled_without_sessions_even_if_primary_later_gets_a_session() {
let temp = tempfile::TempDir::new().unwrap();
let (events, _) = bounded(1);
let mut app = super::super::tests::test_app(&temp, events);
let mut ui = state::MissionControlState::default();
app.state.current_session = Some(app.state.session_manager.create().unwrap());
app.open_side(&mut ui);
assert!(ui.conversation_modal.is_none());
assert!(ui.status.contains("--no-session"));
}
#[test]
fn side_reopen_keeps_draft_history_and_session_while_primary_switches() {
let temp = tempfile::TempDir::new().unwrap();
let (mut app, mut ui) = ready_app(&temp);
let side = app.side.conversation.as_ref().unwrap();
let side_id = side.state.active_session_id().unwrap().to_string();
assert!(
side.state
.current_session
.as_ref()
.unwrap()
.path()
.starts_with(app.config.paths.sessions.join("side"))
);
let modal = ui.conversation_modal.as_mut().unwrap();
modal.state.set_prompt_text("unfinished side", 15);
modal.state.apply_output_event(&OutputEvent::Diagnostic {
level: "info".into(),
message: "side history".into(),
});
let history_len = modal.state.transcript.len();
key(&mut app, &mut ui, KeyCode::Esc, KeyModifiers::NONE);
assert!(!ui.conversation_modal.as_ref().unwrap().visible);
ui.reset_for_new_session();
app.state.current_session = Some(app.state.session_manager.create().unwrap());
app.open_side(&mut ui);
let modal = ui.conversation_modal.as_ref().unwrap();
assert_eq!(modal.state.prompt_plain_text(), "unfinished side");
assert!(!modal.state.activity_column_visible());
assert!(ui.activity_column_visible());
assert_eq!(modal.state.transcript.len(), history_len);
assert!(ui.transcript.is_empty());
assert_eq!(
app.side
.conversation
.as_ref()
.unwrap()
.state
.active_session_id(),
Some(side_id.as_str())
);
}
#[test]
fn side_hidden_channel_drains_bounded_and_marks_unread_without_primary_output() {
let temp = tempfile::TempDir::new().unwrap();
let (mut app, mut ui) = ready_app(&temp);
key(&mut app, &mut ui, KeyCode::Esc, KeyModifiers::NONE);
let sender = app.side.conversation.as_ref().unwrap().events.clone();
for index in 0..300 {
sender
.try_send(TuiEvent::Output(OutputEvent::Diagnostic {
level: "info".into(),
message: format!("side update {index}"),
}))
.unwrap();
}
app.poll_side(&mut ui);
assert!(app.side.conversation.as_ref().unwrap().receiver.len() >= 172);
assert!(ui.conversation_modal.as_ref().unwrap().unread);
assert!(ui.transcript.is_empty());
poll_until(&mut app, &mut ui, |app| {
app.side.conversation.as_ref().unwrap().receiver.is_empty()
});
app.open_side(&mut ui);
assert!(!ui.conversation_modal.as_ref().unwrap().unread);
}
#[test]
fn side_cancel_and_reset_never_cancel_primary_or_replace_a_live_worker() {
let temp = tempfile::TempDir::new().unwrap();
let (mut app, mut ui) = ready_app(&temp);
let (primary_worker, primary_release, primary_cancel) = controlled_worker(app.events.clone());
app.worker = Some(primary_worker);
app.active_run = true;
let side = app.side.conversation.as_mut().unwrap();
let old_session = side.state.current_session.as_ref().unwrap().clone();
record_session_event(
Some(&old_session),
&side.state.cwd,
SessionEventKind::Diagnostic,
serde_json::json!({"level":"info", "message":"archived side history"}),
)
.unwrap();
let old_sender = side.events.clone();
let (worker, release, cancel) = controlled_worker(side.events.clone());
ui.conversation_modal
.as_mut()
.unwrap()
.state
.active_worker_id = Some(worker.outcome.worker_id());
side.worker = Some(worker);
side.active_run = true;
key(&mut app, &mut ui, KeyCode::Char('c'), KeyModifiers::ALT);
assert!(cancel.load(Ordering::SeqCst));
assert!(!primary_cancel.load(Ordering::SeqCst));
let mut modal = ui.conversation_modal.take().unwrap();
app.submit_side("/reset".into(), &mut modal);
app.submit_side("not admitted during reset".into(), &mut modal);
ui.conversation_modal = Some(modal);
app.poll_side(&mut ui);
assert_eq!(
app.side
.conversation
.as_ref()
.unwrap()
.state
.active_session_id(),
Some(old_session.id())
);
assert!(app.side.creation.is_none());
release.send(()).unwrap();
poll_until(&mut app, &mut ui, |app| {
app.side
.conversation
.as_ref()
.is_some_and(|side| side.state.active_session_id() != Some(old_session.id()))
&& app.side.creation.is_none()
});
assert!(old_session.path().exists());
let side_ui = &mut ui.conversation_modal.as_mut().unwrap().state;
side_ui.toggle_activity_column();
assert!(!side_ui.activity_column_visible());
assert!(ui.activity_column_visible());
assert!(
old_sender
.try_send(TuiEvent::Error("late old event".into()))
.is_err()
);
assert!(!primary_cancel.load(Ordering::SeqCst));
assert!(app.active_run);
primary_release.send(()).unwrap();
app.cleanup_after_run().unwrap();
}
#[test]
fn side_rejects_primary_slashes_including_quit_and_keeps_draft() {
let temp = tempfile::TempDir::new().unwrap();
let (mut app, mut ui) = ready_app(&temp);
let id = app
.side
.conversation
.as_ref()
.unwrap()
.state
.active_session_id()
.unwrap()
.to_string();
for text in ["/quit", "/model other", "/new", "/side", "/reset extra"] {
ui.conversation_modal
.as_mut()
.unwrap()
.state
.set_prompt_text(text, text.len());
key(&mut app, &mut ui, KeyCode::Enter, KeyModifiers::NONE);
let modal = ui.conversation_modal.as_ref().unwrap();
assert!(modal.visible);
assert_eq!(modal.state.prompt_plain_text(), text);
assert!(modal.state.status.contains("Only /reset"));
assert!(app.side.conversation.as_ref().unwrap().worker.is_none());
assert_eq!(
app.side
.conversation
.as_ref()
.unwrap()
.state
.active_session_id(),
Some(id.as_str())
);
}
}
#[test]
fn side_paste_and_copy_use_normal_display_reducer() {
let temp = tempfile::TempDir::new().unwrap();
let (mut app, mut ui) = ready_app(&temp);
let mut clipboard = Clipboard::default();
app.handle_side_input(
TerminalInputEvent::Event(Event::Paste("hello\nworld".into())),
&mut ui,
ratatui::layout::Rect::new(0, 0, 100, 30),
&mut clipboard,
&[],
);
let modal = ui.conversation_modal.as_mut().unwrap();
assert_eq!(modal.state.prompt_plain_text(), "hello\nworld");
app.apply_side_input(
input::InputCommand::action(input::InputAction::CopySelectedPrompt(
"selected side".into(),
)),
modal,
&[],
&mut clipboard,
);
assert_eq!(clipboard.0, "selected side");
assert!(ui.prompt_is_empty());
}
#[test]
fn side_provider_pair_is_fixed_until_reset_and_settings_edits_need_restart() {
let temp = tempfile::TempDir::new().unwrap();
let (mut app, mut ui) = ready_app(&temp);
app.state.model = "new-primary-model".into();
app.settings.side.provider = Some("not-loaded-until-restart".into());
app.settings.side.model = Some("not-loaded-until-restart".into());
assert_eq!(
app.side.conversation.as_ref().unwrap().state.model,
"test-model"
);
let old_id = app
.side
.conversation
.as_ref()
.unwrap()
.state
.active_session_id()
.unwrap()
.to_string();
let mut modal = ui.conversation_modal.take().unwrap();
app.submit_side("/reset".into(), &mut modal);
ui.conversation_modal = Some(modal);
poll_until(&mut app, &mut ui, |app| {
app.side
.conversation
.as_ref()
.is_some_and(|side| side.state.active_session_id() != Some(old_id.as_str()))
&& app.side.creation.is_none()
});
assert_eq!(
app.side.conversation.as_ref().unwrap().state.model,
"new-primary-model"
);
}
#[test]
fn side_abnormal_worker_exit_reconciles_without_completion_marker() {
let temp = tempfile::TempDir::new().unwrap();
let (mut app, mut ui) = ready_app(&temp);
let side = app.side.conversation.as_mut().unwrap();
side.worker = Some(WorkerState {
handle: thread::spawn(|| {}),
cancel: Arc::new(AtomicBool::new(false)),
login_manual: None,
outcome: Arc::new(WorkerOutcomeState::default()),
outcome_reconciled: false,
shutdown_policy: WorkerShutdownPolicy::Cancel,
steering: crate::agent::steering::AgentSteering::new(),
accepts_steering: true,
});
side.active_run = true;
poll_until(&mut app, &mut ui, |app| {
app.side.conversation.as_ref().unwrap().worker.is_none()
});
assert!(
ui.conversation_modal
.as_ref()
.unwrap()
.state
.status
.contains("worker exited unexpectedly")
);
assert!(!app.side.conversation.as_ref().unwrap().active_run);
assert!(ui.transcript.is_empty());
}
#[test]
fn side_shared_runner_displays_bash_output_without_persisting_history() {
let temp = tempfile::TempDir::new().unwrap();
let (mut app, mut ui) = ready_app(&temp);
let primary = app.state.current_session.as_ref().unwrap().clone();
record_session_event(
Some(&primary),
&app.state.cwd,
SessionEventKind::UserInput,
serde_json::json!({"text":"primary private history"}),
)
.unwrap();
let primary_events = primary.read_events().unwrap().len();
let mut modal = ui.conversation_modal.take().unwrap();
app.submit_side("!printf side-tool-result".into(), &mut modal);
assert!(app.side.conversation.as_ref().unwrap().worker.is_some());
assert!(app.worker.is_none());
ui.conversation_modal = Some(modal);
poll_until(&mut app, &mut ui, |app| {
app.side.conversation.as_ref().unwrap().worker.is_none()
});
let side = app
.side
.conversation
.as_ref()
.unwrap()
.state
.current_session
.as_ref()
.unwrap();
assert!(side.read_events().unwrap().iter().all(|event| {
!matches!(
event.event_type.as_str(),
"user_input" | "tool_call" | "tool_result"
)
}));
assert_eq!(primary.read_events().unwrap().len(), primary_events);
let transcript = &ui.conversation_modal.as_ref().unwrap().state.transcript;
assert!(
transcript
.iter()
.any(|entry| entry.contains("side-tool-result"))
);
assert!(
!transcript
.iter()
.any(|entry| entry.contains("primary private history"))
);
assert!(ui.transcript.is_empty());
app.cleanup_after_run().unwrap();
}
#[cfg(unix)]
#[test]
fn side_mcp_cancel_and_reset_leave_primary_connection_usable() {
use crate::mcp::manager::McpManager;
let temp = tempfile::TempDir::new().unwrap();
let (events, _) = bounded(1024);
let mut app = super::super::tests::test_app(&temp, events);
app.side.enabled = true;
let accepted = temp.path().join("accepted");
let release = temp.path().join("release");
app.settings.mcp_servers.insert(
"mock".into(),
crate::config::McpServerConfig::Stdio(crate::config::McpStdioServerConfig {
command: "sh".into(),
args: vec![format!(
"{}/tests/fixtures/mcp/mock_stdio_server.sh",
env!("CARGO_MANIFEST_DIR")
)],
env: std::collections::BTreeMap::from([
("MCP_MOCK_MODE".into(), "blocked_tool_call".into()),
(
"MCP_MOCK_TOOL_ACCEPTED_FILE".into(),
accepted.display().to_string(),
),
(
"MCP_MOCK_TOOL_RELEASE_FILE".into(),
release.display().to_string(),
),
]),
enabled: true,
timeout: Some(5),
}),
);
app.mcp = Some(Arc::new(std::sync::Mutex::new(
McpManager::from_settings_strict(&app.settings.mcp_servers, None).unwrap(),
)));
let primary = app.mcp.clone().unwrap();
let mut ui = state::MissionControlState::default();
app.open_side(&mut ui);
poll_until(&mut app, &mut ui, |app| {
app.side.conversation.is_some() && app.side.creation.is_none()
});
let mut side = app.side.conversation.take().unwrap();
let side_call = side.with_app(&mut app, |app| {
let manager = app.mcp.as_ref().unwrap();
assert!(!Arc::ptr_eq(manager, &primary));
manager
.lock()
.unwrap()
.resolve_tool_call("mcp__mock__echo")
.unwrap()
});
assert!(Arc::ptr_eq(app.mcp.as_ref().unwrap(), &primary));
app.side.conversation = Some(side);
let (cancellation, cancel) = crate::cancellation::AgentCancellation::default().child_token();
let call = thread::spawn(move || side_call.call_tool_cancellable(None, &cancellation));
let deadline = Instant::now() + Duration::from_secs(5);
while !accepted.exists() {
assert!(Instant::now() < deadline, "side tool never started");
thread::sleep(Duration::from_millis(1));
}
cancel.cancel();
assert!(call.join().unwrap().is_err());
std::fs::write(&release, "").unwrap();
assert!(
primary
.lock()
.unwrap()
.call_tool("mcp__mock__echo", None)
.is_ok()
);
app.side.reset_requested = true;
app.poll_side(&mut ui);
poll_until(&mut app, &mut ui, |app| {
app.side.conversation.is_some() && app.side.creation.is_none()
});
assert!(
primary
.lock()
.unwrap()
.call_tool("mcp__mock__echo", None)
.is_ok()
);
assert!(
app.side
.conversation
.as_ref()
.unwrap()
.mcp
.as_ref()
.unwrap()
.lock()
.unwrap()
.call_tool("mcp__mock__echo", None)
.is_ok()
);
assert!(app.side.cleanup().is_empty());
assert!(
primary
.lock()
.unwrap()
.call_tool("mcp__mock__echo", None)
.is_ok()
);
}
#[cfg(unix)]
#[test]
fn side_shutdown_cancels_pending_mcp_initialization() {
let temp = tempfile::TempDir::new().unwrap();
let (events, _) = bounded(1024);
let mut app = super::super::tests::test_app(&temp, events);
app.side.enabled = true;
let requested = temp.path().join("initialize-requested");
app.settings.mcp_servers.insert(
"mock".into(),
crate::config::McpServerConfig::Stdio(crate::config::McpStdioServerConfig {
command: "sh".into(),
args: vec![format!(
"{}/tests/fixtures/mcp/mock_stdio_server.sh",
env!("CARGO_MANIFEST_DIR")
)],
env: std::collections::BTreeMap::from([
("MCP_MOCK_MODE".into(), "hang".into()),
(
"MCP_MOCK_TEST_REQUEST_FILE".into(),
requested.display().to_string(),
),
]),
enabled: true,
timeout: Some(30),
}),
);
let mut ui = state::MissionControlState::default();
app.open_side(&mut ui);
let deadline = Instant::now() + Duration::from_secs(5);
while !requested.exists() {
assert!(
Instant::now() < deadline,
"side initialization never started"
);
thread::sleep(Duration::from_millis(1));
}
app.side.request_shutdown();
poll_until(&mut app, &mut ui, |app| app.side.creation.is_none());
assert!(app.side.conversation.is_none());
assert!(app.side.cleanup().is_empty());
}