Codex Handbook
tui/src/chatwidget/tests/side.rs 423 lines
use super::*;use pretty_assertions::assert_eq;use ratatui::Terminal;use ratatui::backend::TestBackend;#[tokio::test]async fn forked_thread_history_line_without_name_shows_id_once_snapshot() {    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;    let forked_from_id =        ThreadId::from_string("019c2d47-4935-7423-a190-05691f566092").expect("forked id");    chat.emit_forked_thread_event(forked_from_id, /*fork_parent_title*/ None);    let history_cell = tokio::time::timeout(std::time::Duration::from_secs(2), async {        loop {            match rx.recv().await {                Some(AppEvent::InsertHistoryCell(cell)) => break cell,                Some(_) => continue,                None => panic!("app event channel closed before forked thread history was emitted"),            }        }    })    .await    .expect("timed out waiting for forked thread history");    let combined = lines_to_single_string(&history_cell.display_lines(/*width*/ 80));    assert_chatwidget_snapshot!("forked_thread_history_line_without_name", combined);}#[tokio::test]async fn suppressed_interrupted_turn_notice_skips_history_warning() {    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;    chat.thread_id = Some(ThreadId::new());    chat.set_interrupted_turn_notice_mode(InterruptedTurnNoticeMode::Suppress);    chat.on_task_started();    chat.on_agent_message_delta("partial output".to_string());    chat.on_interrupted_turn(TurnAbortReason::Interrupted);    let inserted = drain_insert_history(&mut rx);    assert!(        inserted.iter().all(|cell| {            let rendered = lines_to_single_string(cell);            !rendered.contains("Conversation interrupted - tell the model what to do differently.")                && !rendered.contains("Model interrupted to submit steer instructions.")        }),        "unexpected interrupted-turn notice in side conversation: {inserted:?}"    );}fn assert_side_rename_rejected(    rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,    op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>,) {    let event = rx        .try_recv()        .expect("expected side conversation rename error");    match event {        AppEvent::InsertHistoryCell(cell) => {            let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80));            assert!(                rendered.contains("Side conversations are ephemeral and cannot be renamed."),                "expected side conversation rename error, got {rendered:?}"            );        }        other => panic!("expected InsertHistoryCell error, got {other:?}"),    }    assert!(rx.try_recv().is_err(), "expected no follow-up events");    assert!(op_rx.try_recv().is_err(), "expected no rename op");}#[tokio::test]async fn slash_rename_is_rejected_for_side_threads() {    let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;    chat.set_thread_rename_block_message(        "Side conversations are ephemeral and cannot be renamed.".to_string(),    );    chat.dispatch_command(SlashCommand::Rename);    assert_side_rename_rejected(&mut rx, &mut op_rx);}#[tokio::test]async fn slash_rename_with_args_is_rejected_for_side_threads() {    let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;    chat.set_thread_rename_block_message(        "Side conversations are ephemeral and cannot be renamed.".to_string(),    );    chat.dispatch_command_with_args(SlashCommand::Rename, "investigate".to_string(), Vec::new());    assert_side_rename_rejected(&mut rx, &mut op_rx);}#[tokio::test]async fn slash_commands_without_side_flag_are_rejected_for_side_threads() {    let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;    chat.set_side_conversation_active(/*active*/ true);    chat.dispatch_command(SlashCommand::Review);    let event = rx        .try_recv()        .expect("expected side conversation slash command error");    match event {        AppEvent::InsertHistoryCell(cell) => {            let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80));            assert!(                rendered.contains(                    "'/review' is unavailable in side conversations. Press Ctrl+C to return to the main thread first."                ),                "expected side conversation slash command error, got {rendered:?}"            );        }        other => panic!("expected InsertHistoryCell error, got {other:?}"),    }    assert!(rx.try_recv().is_err(), "expected no follow-up events");    assert!(op_rx.try_recv().is_err(), "expected no review op");}#[tokio::test]async fn slash_side_is_rejected_for_side_threads() {    let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;    chat.set_side_conversation_active(/*active*/ true);    chat.dispatch_command(SlashCommand::Side);    let event = rx        .try_recv()        .expect("expected side conversation slash command error");    match event {        AppEvent::InsertHistoryCell(cell) => {            let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80));            assert!(                rendered.contains(                    "'/side' is unavailable in side conversations. Press Ctrl+C to return to the main thread first."                ),                "expected side conversation slash command error, got {rendered:?}"            );        }        other => panic!("expected InsertHistoryCell error, got {other:?}"),    }    assert!(rx.try_recv().is_err(), "expected no follow-up events");    assert!(        op_rx.try_recv().is_err(),        "expected no side conversation op"    );}#[tokio::test]async fn slash_side_is_rejected_during_review_mode() {    let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;    chat.review.is_review_mode = true;    chat.dispatch_command(SlashCommand::Side);    let event = rx        .try_recv()        .expect("expected review-mode side conversation error");    match event {        AppEvent::InsertHistoryCell(cell) => {            let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80));            assert!(                rendered.contains("'/side' is unavailable while code review is running."),                "expected review-mode side conversation error, got {rendered:?}"            );        }        other => panic!("expected InsertHistoryCell error, got {other:?}"),    }    assert!(rx.try_recv().is_err(), "expected no follow-up events");    assert!(        op_rx.try_recv().is_err(),        "expected no side conversation op"    );}#[tokio::test]async fn slash_btw_is_rejected_during_review_mode() {    let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;    chat.review.is_review_mode = true;    chat.dispatch_command(SlashCommand::Btw);    let event = rx        .try_recv()        .expect("expected review-mode btw conversation error");    match event {        AppEvent::InsertHistoryCell(cell) => {            let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80));            assert!(                rendered.contains("'/btw' is unavailable while code review is running."),                "expected review-mode btw conversation error, got {rendered:?}"            );        }        other => panic!("expected InsertHistoryCell error, got {other:?}"),    }    assert!(rx.try_recv().is_err(), "expected no follow-up events");    assert!(        op_rx.try_recv().is_err(),        "expected no side conversation op"    );}#[tokio::test]async fn slash_btw_is_rejected_before_the_session_starts() {    let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;    chat.dispatch_command(SlashCommand::Btw);    let event = rx        .try_recv()        .expect("expected pre-session btw conversation error");    match event {        AppEvent::InsertHistoryCell(cell) => {            let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80));            assert!(                rendered.contains("'/btw' is unavailable before the session starts."),                "expected pre-session btw conversation error, got {rendered:?}"            );        }        other => panic!("expected InsertHistoryCell error, got {other:?}"),    }    assert!(rx.try_recv().is_err(), "expected no follow-up events");    assert!(        op_rx.try_recv().is_err(),        "expected no side conversation op"    );}#[tokio::test]async fn submit_user_message_as_plain_user_turn_does_not_run_shell_commands() {    let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;    chat.thread_id = Some(ThreadId::new());    chat.submit_user_message_as_plain_user_turn("!echo hello".into());    match next_submit_op(&mut op_rx) {        Op::UserTurn { items, .. } => assert_eq!(            items,            vec![UserInput::Text {                text: "!echo hello".to_string(),                text_elements: Vec::new(),            }]        ),        other => {            panic!("expected Op::UserTurn for side-conversation shell-like input, got {other:?}")        }    }}#[tokio::test]async fn slash_side_without_args_starts_empty_side_conversation() {    let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;    let parent_thread_id = ThreadId::new();    chat.thread_id = Some(parent_thread_id);    chat.on_task_started();    chat.bottom_pane        .set_composer_text("/side".to_string(), Vec::new(), Vec::new());    chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));    assert_matches!(        rx.try_recv(),        Ok(AppEvent::StartSide {            parent_thread_id: emitted_parent_thread_id,            user_message: None,        }) if emitted_parent_thread_id == parent_thread_id    );    assert!(        op_rx.try_recv().is_err(),        "bare /side should not submit an op on the parent thread"    );    assert!(chat.input_queue.queued_user_messages.is_empty());}#[tokio::test]async fn slash_btw_without_args_starts_empty_side_conversation() {    let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;    let parent_thread_id = ThreadId::new();    chat.thread_id = Some(parent_thread_id);    chat.on_task_started();    chat.bottom_pane        .set_composer_text("/btw".to_string(), Vec::new(), Vec::new());    chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));    assert_matches!(        rx.try_recv(),        Ok(AppEvent::StartSide {            parent_thread_id: emitted_parent_thread_id,            user_message: None,        }) if emitted_parent_thread_id == parent_thread_id    );    assert!(        op_rx.try_recv().is_err(),        "bare /btw should not submit an op on the parent thread"    );    assert!(chat.input_queue.queued_user_messages.is_empty());}#[tokio::test]async fn slash_side_requests_forked_side_question_while_task_running() {    let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;    let parent_thread_id = ThreadId::new();    chat.thread_id = Some(parent_thread_id);    chat.config.tui_status_line = Some(vec!["model-with-reasoning".to_string()]);    chat.refresh_status_line();    chat.on_task_started();    chat.show_welcome_banner = false;    chat.bottom_pane.set_composer_text(        "/side explore the codebase".to_string(),        Vec::new(),        Vec::new(),    );    chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));    assert_matches!(        rx.try_recv(),        Ok(AppEvent::StartSide {            parent_thread_id: emitted_parent_thread_id,            user_message: Some(user_message),        }) if emitted_parent_thread_id == parent_thread_id            && user_message                == UserMessage {                    text: "explore the codebase".to_string(),                    local_images: Vec::new(),                    remote_image_urls: Vec::new(),                    text_elements: Vec::new(),                    mention_bindings: Vec::new(),                }    );    assert!(        op_rx.try_recv().is_err(),        "expected no op on the parent thread"    );    let width = 80;    let height = chat.desired_height(width);    let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal");    terminal        .draw(|f| chat.render(f.area(), f.buffer_mut()))        .expect("draw side conversation footer");    assert_chatwidget_snapshot!(        "slash_side_requests_forked_side_question_while_task_running",        normalized_backend_snapshot(terminal.backend())    );}#[tokio::test]async fn slash_btw_requests_forked_side_question_while_task_running() {    let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;    let parent_thread_id = ThreadId::new();    chat.thread_id = Some(parent_thread_id);    chat.on_task_started();    chat.bottom_pane.set_composer_text(        "/btw explore the codebase".to_string(),        Vec::new(),        Vec::new(),    );    chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));    assert_matches!(        rx.try_recv(),        Ok(AppEvent::StartSide {            parent_thread_id: emitted_parent_thread_id,            user_message: Some(user_message),        }) if emitted_parent_thread_id == parent_thread_id            && user_message                == UserMessage {                    text: "explore the codebase".to_string(),                    local_images: Vec::new(),                    remote_image_urls: Vec::new(),                    text_elements: Vec::new(),                    mention_bindings: Vec::new(),                }    );    assert!(        op_rx.try_recv().is_err(),        "expected no op on the parent thread"    );}#[tokio::test]async fn side_context_label_preserves_status_line_snapshot() {    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;    chat.show_welcome_banner = false;    chat.config.tui_status_line = Some(vec!["model-name".to_string()]);    chat.refresh_status_line();    chat.set_side_conversation_active(/*active*/ true);    chat.set_side_conversation_context_label(Some(        "Side from main thread · Ctrl+C to return".to_string(),    ));    let width = 80;    let height = chat.desired_height(width);    let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal");    terminal        .draw(|f| chat.render(f.area(), f.buffer_mut()))        .expect("draw side conversation footer");    assert_chatwidget_snapshot!(        "side_context_label_preserves_status_line",        terminal.backend()    );}#[tokio::test]async fn side_context_label_shows_parent_status_snapshot() {    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;    chat.show_welcome_banner = false;    chat.set_side_conversation_active(/*active*/ true);    chat.set_side_conversation_context_label(Some(        "Side from main thread · main needs input · Ctrl+C to return".to_string(),    ));    let width = 80;    let height = chat.desired_height(width);    let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal");    terminal        .draw(|f| chat.render(f.area(), f.buffer_mut()))        .expect("draw side conversation footer");    assert_chatwidget_snapshot!("side_context_label_shows_parent_status", terminal.backend());}