Codex Handbook
tui/src/chatwidget/tests/plan_mode.rs 1666 lines
use super::*;use pretty_assertions::assert_eq;#[test]fn plan_mode_nudge_matches_only_standalone_plain_text_keyword() {    assert!(contains_plan_keyword("plan"));    assert!(contains_plan_keyword("Make a Plan first."));    assert!(!contains_plan_keyword("plane"));    assert!(!contains_plan_keyword("planning"));    assert!(contains_plan_keyword("/plan"));    assert!(contains_plan_keyword("!plan"));}#[tokio::test]async fn plan_mode_nudge_shows_only_for_eligible_default_mode_drafts() {    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;    chat.set_composer_text("make a plan".to_string(), Vec::new(), Vec::new());    chat.pre_draw_tick();    assert!(chat.bottom_pane.plan_mode_nudge_visible());    chat.set_composer_text("/plan".to_string(), Vec::new(), Vec::new());    chat.pre_draw_tick();    assert!(!chat.bottom_pane.plan_mode_nudge_visible());    chat.set_composer_text("!plan".to_string(), Vec::new(), Vec::new());    chat.pre_draw_tick();    assert!(!chat.bottom_pane.plan_mode_nudge_visible());    chat.set_composer_text("make a plan".to_string(), Vec::new(), Vec::new());    let plan_mask = collaboration_modes::plan_mask(chat.model_catalog.as_ref())        .expect("expected plan collaboration mode");    chat.set_collaboration_mask(plan_mask);    chat.pre_draw_tick();    assert!(!chat.bottom_pane.plan_mode_nudge_visible());}#[tokio::test]async fn plan_mode_nudge_hides_while_task_or_modal_is_active() {    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;    chat.set_composer_text("make a plan".to_string(), Vec::new(), Vec::new());    chat.pre_draw_tick();    assert!(chat.bottom_pane.plan_mode_nudge_visible());    chat.on_task_started();    chat.pre_draw_tick();    assert!(!chat.bottom_pane.plan_mode_nudge_visible());    chat.on_task_complete(        /*last_agent_message*/ None, /*duration_ms*/ None, /*from_replay*/ false,    );    chat.show_selection_view(SelectionViewParams {        items: vec![SelectionItem {            name: "Keep planning".to_string(),            ..Default::default()        }],        ..Default::default()    });    chat.pre_draw_tick();    assert!(!chat.bottom_pane.plan_mode_nudge_visible());}#[tokio::test]async fn plan_mode_nudge_dismissal_is_scoped_to_current_thread() {    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;    let first_thread = ThreadId::new();    let second_thread = ThreadId::new();    chat.thread_id = Some(first_thread);    chat.set_composer_text("make a plan".to_string(), Vec::new(), Vec::new());    chat.pre_draw_tick();    assert!(chat.bottom_pane.plan_mode_nudge_visible());    chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));    chat.pre_draw_tick();    assert!(!chat.bottom_pane.plan_mode_nudge_visible());    chat.thread_id = Some(second_thread);    chat.pre_draw_tick();    assert!(chat.bottom_pane.plan_mode_nudge_visible());    chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));    chat.pre_draw_tick();    assert!(!chat.bottom_pane.plan_mode_nudge_visible());    chat.thread_id = Some(first_thread);    chat.pre_draw_tick();    assert!(!chat.bottom_pane.plan_mode_nudge_visible());}#[tokio::test]async fn plan_mode_nudge_shift_tab_uses_existing_mode_cycle_path() {    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;    chat.set_composer_text("make a plan".to_string(), Vec::new(), Vec::new());    chat.pre_draw_tick();    assert!(chat.bottom_pane.plan_mode_nudge_visible());    chat.handle_key_event(KeyEvent::from(KeyCode::BackTab));    chat.pre_draw_tick();    assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);    assert!(!chat.bottom_pane.plan_mode_nudge_visible());}#[tokio::test]async fn plan_mode_nudge_snapshot() {    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;    chat.set_token_info(Some(make_token_info(        /*total_tokens*/ 50_000, /*context_window*/ 100_000,    )));    chat.set_composer_text("make a plan".to_string(), Vec::new(), Vec::new());    chat.pre_draw_tick();    assert_chatwidget_snapshot!("plan_mode_nudge", render_bottom_popup(&chat, /*width*/ 80));}#[tokio::test]async fn plan_mode_nudge_narrow_snapshot() {    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;    chat.set_composer_text("make a plan".to_string(), Vec::new(), Vec::new());    chat.pre_draw_tick();    assert_chatwidget_snapshot!(        "plan_mode_nudge_narrow",        render_bottom_popup(&chat, /*width*/ 36)    );}#[tokio::test]async fn plan_implementation_popup_snapshot() {    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;    chat.on_plan_item_completed("- Step 1\n- Step 2\n".to_string());    chat.open_plan_implementation_prompt();    let popup = render_bottom_popup(&chat, /*width*/ 80);    assert_chatwidget_snapshot!("plan_implementation_popup", popup);}#[tokio::test]async fn plan_implementation_popup_context_usage_snapshot() {    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;    chat.set_token_info(Some(make_token_info(        /*total_tokens*/ 90_000, /*context_window*/ 100_000,    )));    chat.on_plan_item_completed("- Step 1\n- Step 2\n".to_string());    chat.open_plan_implementation_prompt();    let popup = render_bottom_popup(&chat, /*width*/ 80);    assert_chatwidget_snapshot!("plan_implementation_popup_context_usage", popup);}#[tokio::test]async fn plan_implementation_popup_no_selected_snapshot() {    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;    chat.on_plan_item_completed("- Step 1\n- Step 2\n".to_string());    chat.open_plan_implementation_prompt();    chat.handle_key_event(KeyEvent::from(KeyCode::Down));    let popup = render_bottom_popup(&chat, /*width*/ 80);    assert_chatwidget_snapshot!("plan_implementation_popup_no_selected", popup);}#[tokio::test]async fn plan_implementation_popup_yes_emits_submit_message_event() {    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;    chat.open_plan_implementation_prompt();    chat.handle_key_event(KeyEvent::from(KeyCode::Enter));    let event = rx.try_recv().expect("expected AppEvent");    let AppEvent::SubmitUserMessageWithMode {        text,        collaboration_mode,    } = event    else {        panic!("expected SubmitUserMessageWithMode, got {event:?}");    };    assert_eq!(        text,        plan_implementation::PLAN_IMPLEMENTATION_CODING_MESSAGE    );    assert_eq!(collaboration_mode.mode, Some(ModeKind::Default));}#[tokio::test]async fn plan_implementation_popup_clear_context_emits_clear_submit_event() {    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;    let plan_markdown = "- Step 1\n- Step 2\n";    chat.on_plan_item_completed(plan_markdown.to_string());    let _ = drain_insert_history(&mut rx);    chat.open_plan_implementation_prompt();    chat.handle_key_event(KeyEvent::from(KeyCode::Down));    chat.handle_key_event(KeyEvent::from(KeyCode::Enter));    let event = rx.try_recv().expect("expected AppEvent");    let AppEvent::ClearUiAndSubmitUserMessage { text } = event else {        panic!("expected ClearUiAndSubmitUserMessage, got {event:?}");    };    assert_eq!(        text,        "A previous agent produced the plan below to accomplish the user's task. \        Implement the plan in a fresh context. Treat the plan as the source of \        user intent, re-read files as needed, and carry the work through \        implementation and verification.\n\n- Step 1\n- Step 2\n"    );}#[tokio::test]async fn plan_implementation_clear_context_requires_default_mode_and_plan() {    let (chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;    let default_mask = collaboration_modes::default_mode_mask(chat.model_catalog.as_ref())        .expect("expected default collaboration mode");    let params = plan_implementation::selection_view_params(        /*default_mask*/ None,        Some("- Step\n"),        /*clear_context_usage_label*/ None,    );    assert_eq!(        params.items[1].disabled_reason.as_deref(),        Some(plan_implementation::PLAN_IMPLEMENTATION_DEFAULT_UNAVAILABLE)    );    let params = plan_implementation::selection_view_params(        Some(default_mask.clone()),        /*plan_markdown*/ None,        /*clear_context_usage_label*/ None,    );    assert_eq!(        params.items[1].disabled_reason.as_deref(),        Some(plan_implementation::PLAN_IMPLEMENTATION_NO_APPROVED_PLAN)    );    let params = plan_implementation::selection_view_params(        Some(default_mask.clone()),        Some("  \n"),        /*clear_context_usage_label*/ None,    );    assert_eq!(        params.items[1].disabled_reason.as_deref(),        Some(plan_implementation::PLAN_IMPLEMENTATION_NO_APPROVED_PLAN)    );    let params = plan_implementation::selection_view_params(        Some(default_mask.clone()),        Some("- Step\n"),        /*clear_context_usage_label*/ None,    );    assert_eq!(params.items[1].disabled_reason, None);    assert!(!params.items[1].actions.is_empty());    assert_eq!(        params.items[1].description.as_deref(),        Some("Fresh thread with this plan.")    );    let params = plan_implementation::selection_view_params(        Some(default_mask),        Some("- Step\n"),        Some("89% used"),    );    assert_eq!(        params.items[1].description.as_deref(),        Some("Fresh thread. Context: 89% used.")    );}#[tokio::test]async fn submit_user_message_with_mode_sets_coding_collaboration_mode() {    let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await;    chat.thread_id = Some(ThreadId::new());    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);    let default_mode = collaboration_modes::default_mode_mask(chat.model_catalog.as_ref())        .expect("expected default collaboration mode");    chat.submit_user_message_with_mode("Implement the plan.".to_string(), default_mode);    match next_submit_op(&mut op_rx) {        Op::UserTurn {            collaboration_mode:                Some(CollaborationMode {                    mode: ModeKind::Default,                    ..                }),            personality: None,            ..        } => {}        other => {            panic!("expected Op::UserTurn with default collab mode, got {other:?}")        }    }}#[tokio::test]async fn reasoning_selection_in_plan_mode_opens_scope_prompt_event() {    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;    chat.thread_id = Some(ThreadId::new());    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);    let plan_mask = collaboration_modes::plan_mask(chat.model_catalog.as_ref())        .expect("expected plan collaboration mode");    chat.set_collaboration_mask(plan_mask);    let _ = drain_insert_history(&mut rx);    set_chatgpt_auth(&mut chat);    chat.set_reasoning_effort(Some(ReasoningEffortConfig::High));    let preset = get_available_model(&chat, "gpt-5.4");    chat.open_reasoning_popup(preset);    chat.handle_key_event(KeyEvent::from(KeyCode::Down));    chat.handle_key_event(KeyEvent::from(KeyCode::Enter));    let event = rx.try_recv().expect("expected AppEvent");    assert_matches!(        event,        AppEvent::OpenPlanReasoningScopePrompt {            model,            effort: Some(_)        } if model == "gpt-5.4"    );}#[tokio::test]async fn reasoning_selection_in_plan_mode_without_effort_change_does_not_open_scope_prompt_event() {    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;    chat.thread_id = Some(ThreadId::new());    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);    let plan_mask = collaboration_modes::plan_mask(chat.model_catalog.as_ref())        .expect("expected plan collaboration mode");    chat.set_collaboration_mask(plan_mask);    let _ = drain_insert_history(&mut rx);    set_chatgpt_auth(&mut chat);    chat.set_reasoning_effort(Some(ReasoningEffortConfig::Medium));    let preset = get_available_model(&chat, "gpt-5.4");    chat.open_reasoning_popup(preset);    chat.handle_key_event(KeyEvent::from(KeyCode::Enter));    let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();    assert!(        events.iter().any(|event| matches!(            event,            AppEvent::UpdateModel(model) if model == "gpt-5.4"        )),        "expected model update event; events: {events:?}"    );    assert!(        events            .iter()            .any(|event| matches!(event, AppEvent::UpdateReasoningEffort(Some(_)))),        "expected reasoning update event; events: {events:?}"    );}#[tokio::test]async fn reasoning_selection_in_plan_mode_matching_plan_effort_but_different_global_opens_scope_prompt() {    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;    chat.thread_id = Some(ThreadId::new());    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);    let plan_mask = collaboration_modes::plan_mask(chat.model_catalog.as_ref())        .expect("expected plan collaboration mode");    chat.set_collaboration_mask(plan_mask);    let _ = drain_insert_history(&mut rx);    set_chatgpt_auth(&mut chat);    // Reproduce: Plan effective reasoning remains the preset (medium), but the    // global default differs (high). Pressing Enter on the current Plan choice    // should open the scope prompt rather than silently rewriting the global default.    chat.set_reasoning_effort(Some(ReasoningEffortConfig::High));    let preset = get_available_model(&chat, "gpt-5.4");    chat.open_reasoning_popup(preset);    chat.handle_key_event(KeyEvent::from(KeyCode::Enter));    let event = rx.try_recv().expect("expected AppEvent");    assert_matches!(        event,        AppEvent::OpenPlanReasoningScopePrompt {            model,            effort: Some(ReasoningEffortConfig::Medium)        } if model == "gpt-5.4"    );}#[tokio::test]async fn reasoning_shortcut_in_plan_mode_updates_plan_override_without_prompt_or_persist() {    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;    chat.thread_id = Some(ThreadId::new());    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);    let plan_mask = collaboration_modes::plan_mask(chat.model_catalog.as_ref())        .expect("expected plan collaboration mode");    chat.set_collaboration_mask(plan_mask);    let _ = drain_insert_history(&mut rx);    chat.set_reasoning_effort(Some(ReasoningEffortConfig::High));    chat.handle_key_event(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::ALT));    let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();    assert!(        events.iter().any(|event| matches!(            event,            AppEvent::UpdatePlanModeReasoningEffort(Some(ReasoningEffortConfig::High))        )),        "expected plan reasoning override update event; events: {events:?}"    );    assert!(        events            .iter()            .all(|event| !matches!(event, AppEvent::OpenPlanReasoningScopePrompt { .. })),        "expected no Plan reasoning scope prompt event; events: {events:?}"    );    assert!(        events            .iter()            .all(|event| !matches!(event, AppEvent::PersistPlanModeReasoningEffort(_))),        "expected no Plan reasoning persistence event; events: {events:?}"    );    assert!(        events            .iter()            .all(|event| !matches!(event, AppEvent::PersistModelSelection { .. })),        "expected no global model persistence event; events: {events:?}"    );    assert!(        events            .iter()            .all(|event| !matches!(event, AppEvent::UpdateReasoningEffort(_))),        "expected no global reasoning update event; events: {events:?}"    );}#[tokio::test]async fn plan_mode_reasoning_override_is_marked_current_in_reasoning_popup() {    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);    set_chatgpt_auth(&mut chat);    chat.set_reasoning_effort(Some(ReasoningEffortConfig::High));    chat.set_plan_mode_reasoning_effort(Some(ReasoningEffortConfig::Low));    let plan_mask = collaboration_modes::plan_mask(chat.model_catalog.as_ref())        .expect("expected plan collaboration mode");    chat.set_collaboration_mask(plan_mask);    let preset = get_available_model(&chat, "gpt-5.4");    chat.open_reasoning_popup(preset);    let popup = render_bottom_popup(&chat, /*width*/ 100);    assert!(popup.contains("Low (current)"));    assert!(        !popup.contains("High (current)"),        "expected Plan override to drive current reasoning label, got: {popup}"    );}#[tokio::test]async fn reasoning_selection_in_plan_mode_model_switch_does_not_open_scope_prompt_event() {    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;    chat.thread_id = Some(ThreadId::new());    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);    let plan_mask = collaboration_modes::plan_mask(chat.model_catalog.as_ref())        .expect("expected plan collaboration mode");    chat.set_collaboration_mask(plan_mask);    let _ = drain_insert_history(&mut rx);    set_chatgpt_auth(&mut chat);    let preset = get_available_model(&chat, "gpt-5.2");    chat.open_reasoning_popup(preset);    chat.handle_key_event(KeyEvent::from(KeyCode::Enter));    let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();    assert!(        events.iter().any(|event| matches!(            event,            AppEvent::UpdateModel(model) if model == "gpt-5.2"        )),        "expected model update event; events: {events:?}"    );    assert!(        events            .iter()            .any(|event| matches!(event, AppEvent::UpdateReasoningEffort(Some(_)))),        "expected reasoning update event; events: {events:?}"    );}#[tokio::test]async fn plan_reasoning_scope_popup_all_modes_persists_global_and_plan_override() {    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;    chat.open_plan_reasoning_scope_prompt("gpt-5.4".to_string(), Some(ReasoningEffortConfig::High));    chat.handle_key_event(KeyEvent::from(KeyCode::Down));    chat.handle_key_event(KeyEvent::from(KeyCode::Enter));    let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();    assert!(        events.iter().any(|event| matches!(            event,            AppEvent::UpdatePlanModeReasoningEffort(Some(ReasoningEffortConfig::High))        )),        "expected plan override to be updated; events: {events:?}"    );    assert!(        events.iter().any(|event| matches!(            event,            AppEvent::PersistPlanModeReasoningEffort(Some(ReasoningEffortConfig::High))        )),        "expected updated plan override to be persisted; events: {events:?}"    );    assert!(        events.iter().any(|event| matches!(            event,            AppEvent::PersistModelSelection { model, effort: Some(ReasoningEffortConfig::High) }                if model == "gpt-5.4"        )),        "expected global model reasoning selection persistence; events: {events:?}"    );}#[test]fn plan_mode_prompt_notification_uses_dedicated_type_name() {    let notification = Notification::PlanModePrompt {        title: PLAN_IMPLEMENTATION_TITLE.to_string(),    };    assert!(notification.allowed_for(&Notifications::Custom(        vec!["plan-mode-prompt".to_string(),]    )));    assert!(!notification.allowed_for(&Notifications::Custom(vec![        "approval-requested".to_string(),    ])));    assert_eq!(        notification.display(),        format!("Plan mode prompt: {PLAN_IMPLEMENTATION_TITLE}")    );}#[tokio::test]async fn open_plan_implementation_prompt_sets_pending_notification() {    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;    chat.config.tui_notifications.notifications =        Notifications::Custom(vec!["plan-mode-prompt".to_string()]);    chat.open_plan_implementation_prompt();    assert_matches!(        chat.pending_notification,        Some(Notification::PlanModePrompt { ref title }) if title == PLAN_IMPLEMENTATION_TITLE    );}#[tokio::test]async fn open_plan_reasoning_scope_prompt_sets_pending_notification() {    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;    chat.config.tui_notifications.notifications =        Notifications::Custom(vec!["plan-mode-prompt".to_string()]);    chat.open_plan_reasoning_scope_prompt("gpt-5.4".to_string(), Some(ReasoningEffortConfig::High));    assert_matches!(        chat.pending_notification,        Some(Notification::PlanModePrompt { ref title }) if title == PLAN_MODE_REASONING_SCOPE_TITLE    );}#[tokio::test]async fn agent_turn_complete_does_not_override_pending_plan_mode_prompt_notification() {    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;    chat.open_plan_implementation_prompt();    chat.notify(Notification::AgentTurnComplete {        response: "done".to_string(),    });    assert_matches!(        chat.pending_notification,        Some(Notification::PlanModePrompt { ref title }) if title == PLAN_IMPLEMENTATION_TITLE    );}#[tokio::test]async fn request_user_input_notification_overrides_pending_agent_turn_complete_notification() {    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;    chat.notify(Notification::AgentTurnComplete {        response: "done".to_string(),    });    chat.handle_request_user_input_now(ToolRequestUserInputParams {        thread_id: "thread-1".to_string(),        item_id: "call-1".to_string(),        turn_id: "turn-1".to_string(),        questions: vec![ToolRequestUserInputQuestion {            id: "reasoning_scope".to_string(),            header: "Reasoning scope".to_string(),            question: "Which reasoning scope should I use?".to_string(),            is_other: false,            is_secret: false,            options: Some(vec![ToolRequestUserInputOption {                label: "Plan only".to_string(),                description: "Update only Plan mode.".to_string(),            }]),        }],        auto_resolution_ms: None,    });    assert_matches!(        chat.pending_notification,        Some(Notification::PlanModePrompt { ref title }) if title == "Reasoning scope"    );}#[tokio::test]async fn handle_request_user_input_sets_pending_notification() {    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;    chat.config.tui_notifications.notifications =        Notifications::Custom(vec!["plan-mode-prompt".to_string()]);    chat.handle_request_user_input_now(ToolRequestUserInputParams {        thread_id: "thread-1".to_string(),        item_id: "call-1".to_string(),        turn_id: "turn-1".to_string(),        questions: vec![ToolRequestUserInputQuestion {            id: "reasoning_scope".to_string(),            header: "Reasoning scope".to_string(),            question: "Which reasoning scope should I use?".to_string(),            is_other: false,            is_secret: false,            options: Some(vec![ToolRequestUserInputOption {                label: "Plan only".to_string(),                description: "Update only Plan mode.".to_string(),            }]),        }],        auto_resolution_ms: None,    });    assert_matches!(        chat.pending_notification,        Some(Notification::PlanModePrompt { ref title }) if title == "Reasoning scope"    );}#[tokio::test]async fn plan_reasoning_scope_popup_mentions_selected_reasoning() {    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;    chat.set_plan_mode_reasoning_effort(Some(ReasoningEffortConfig::Low));    chat.open_plan_reasoning_scope_prompt(        "gpt-5.4".to_string(),        Some(ReasoningEffortConfig::Medium),    );    let popup = render_bottom_popup(&chat, /*width*/ 100);    assert!(popup.contains("Choose where to apply medium reasoning."));    assert!(popup.contains("Always use medium reasoning in Plan mode."));    assert!(popup.contains("Apply to Plan mode override"));    assert!(popup.contains("Apply to global default and Plan mode override"));    assert!(popup.contains("user-chosen Plan override (low)"));}#[tokio::test]async fn plan_reasoning_scope_popup_mentions_built_in_plan_default_when_no_override() {    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;    chat.open_plan_reasoning_scope_prompt(        "gpt-5.4".to_string(),        Some(ReasoningEffortConfig::Medium),    );    let popup = render_bottom_popup(&chat, /*width*/ 100);    assert!(popup.contains("built-in Plan default (medium)"));}#[tokio::test]async fn plan_reasoning_scope_popup_plan_only_does_not_update_all_modes_reasoning() {    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;    chat.open_plan_reasoning_scope_prompt("gpt-5.4".to_string(), Some(ReasoningEffortConfig::High));    chat.handle_key_event(KeyEvent::from(KeyCode::Enter));    let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();    assert!(        events.iter().any(|event| matches!(            event,            AppEvent::UpdatePlanModeReasoningEffort(Some(ReasoningEffortConfig::High))        )),        "expected plan-only reasoning update; events: {events:?}"    );    assert!(        events            .iter()            .all(|event| !matches!(event, AppEvent::UpdateReasoningEffort(_))),        "did not expect all-modes reasoning update; events: {events:?}"    );}#[tokio::test]async fn submit_user_message_with_mode_errors_when_mode_changes_during_running_turn() {    let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await;    chat.thread_id = Some(ThreadId::new());    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);    let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)        .expect("expected plan collaboration mask");    chat.set_collaboration_mask(plan_mask);    chat.on_task_started();    let default_mode = collaboration_modes::default_mask(chat.model_catalog.as_ref())        .expect("expected default collaboration mode");    chat.submit_user_message_with_mode("Implement the plan.".to_string(), default_mode);    assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);    assert!(chat.input_queue.queued_user_messages.is_empty());    assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));    let rendered = drain_insert_history(&mut rx)        .iter()        .map(|lines| lines_to_single_string(lines))        .collect::<Vec<_>>()        .join("\n");    assert!(        rendered.contains("Cannot switch collaboration mode while a turn is running."),        "expected running-turn error message, got: {rendered:?}"    );}#[tokio::test]async fn submit_user_message_blocks_when_thread_model_is_unavailable() {    let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;    chat.thread_id = Some(ThreadId::new());    chat.set_model("");    chat.bottom_pane        .set_composer_text("hello".to_string(), Vec::new(), Vec::new());    chat.handle_key_event(KeyEvent::from(KeyCode::Enter));    assert_no_submit_op(&mut op_rx);    let rendered = drain_insert_history(&mut rx)        .iter()        .map(|lines| lines_to_single_string(lines))        .collect::<Vec<_>>()        .join("\n");    assert!(        rendered.contains("Thread model is unavailable."),        "expected unavailable-model error, got: {rendered:?}"    );}#[tokio::test]async fn submit_user_message_with_mode_allows_same_mode_during_running_turn() {    let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await;    chat.thread_id = Some(ThreadId::new());    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);    let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)        .expect("expected plan collaboration mask");    chat.set_collaboration_mask(plan_mask.clone());    chat.on_task_started();    chat.submit_user_message_with_mode("Continue planning.".to_string(), plan_mask);    assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);    assert!(chat.input_queue.queued_user_messages.is_empty());    match next_submit_op(&mut op_rx) {        Op::UserTurn {            collaboration_mode:                Some(CollaborationMode {                    mode: ModeKind::Plan,                    ..                }),            personality: None,            ..        } => {}        other => {            panic!("expected Op::UserTurn with plan collab mode, got {other:?}")        }    }}#[tokio::test]async fn submit_user_message_with_mode_submits_when_plan_stream_is_not_active() {    let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await;    chat.thread_id = Some(ThreadId::new());    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);    let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)        .expect("expected plan collaboration mask");    chat.set_collaboration_mask(plan_mask);    let default_mode = collaboration_modes::default_mask(chat.model_catalog.as_ref())        .expect("expected default collaboration mode");    let expected_mode = default_mode        .mode        .expect("expected default collaboration mode kind");    chat.submit_user_message_with_mode("Implement the plan.".to_string(), default_mode);    assert_eq!(chat.active_collaboration_mode_kind(), expected_mode);    assert!(chat.input_queue.queued_user_messages.is_empty());    match next_submit_op(&mut op_rx) {        Op::UserTurn {            collaboration_mode: Some(CollaborationMode { mode, .. }),            personality: None,            ..        } => assert_eq!(mode, expected_mode),        other => {            panic!("expected Op::UserTurn with default collab mode, got {other:?}")        }    }}#[tokio::test]async fn plan_implementation_popup_skips_replayed_turn_complete() {    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);    let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)        .expect("expected plan collaboration mask");    chat.set_collaboration_mask(plan_mask);    chat.replay_thread_turns(        vec![AppServerTurn {            id: "turn-1".to_string(),            items_view: codex_app_server_protocol::TurnItemsView::Full,            items: vec![AppServerThreadItem::AgentMessage {                id: "msg-plan".to_string(),                text: "Plan details".to_string(),                phase: Some(MessagePhase::FinalAnswer),                memory_citation: None,            }],            status: AppServerTurnStatus::Completed,            error: None,            started_at: None,            completed_at: None,            duration_ms: None,        }],        ReplayKind::ResumeInitialMessages,    );    let popup = render_bottom_popup(&chat, /*width*/ 80);    assert!(        !popup.contains(PLAN_IMPLEMENTATION_TITLE),        "expected no plan popup for replayed turn, got {popup:?}"    );}#[tokio::test]async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_complete() {    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);    let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)        .expect("expected plan collaboration mask");    chat.set_collaboration_mask(plan_mask);    chat.on_task_started();    chat.on_plan_delta("- Step 1\n- Step 2\n".to_string());    chat.on_plan_item_completed("- Step 1\n- Step 2\n".to_string());    chat.replay_thread_turns(        vec![AppServerTurn {            id: "turn-1".to_string(),            items_view: codex_app_server_protocol::TurnItemsView::Full,            items: vec![AppServerThreadItem::AgentMessage {                id: "msg-plan-replay".to_string(),                text: "Plan details".to_string(),                phase: Some(MessagePhase::FinalAnswer),                memory_citation: None,            }],            status: AppServerTurnStatus::Completed,            error: None,            started_at: None,            completed_at: None,            duration_ms: None,        }],        ReplayKind::ResumeInitialMessages,    );    let replay_popup = render_bottom_popup(&chat, /*width*/ 80);    assert!(        !replay_popup.contains(PLAN_IMPLEMENTATION_TITLE),        "expected no prompt for replayed turn completion, got {replay_popup:?}"    );    complete_assistant_message(        &mut chat,        "msg-plan-live-1",        "Plan details",        Some(MessagePhase::FinalAnswer),    );    handle_turn_completed(&mut chat, "live-turn-complete-1", /*duration_ms*/ None);    let popup = render_bottom_popup(&chat, /*width*/ 80);    assert!(        popup.contains(PLAN_IMPLEMENTATION_TITLE),        "expected prompt for first live turn completion after replay, got {popup:?}"    );    chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));    let dismissed_popup = render_bottom_popup(&chat, /*width*/ 80);    assert!(        !dismissed_popup.contains(PLAN_IMPLEMENTATION_TITLE),        "expected prompt to dismiss on Esc, got {dismissed_popup:?}"    );    complete_assistant_message(        &mut chat,        "msg-plan-live-2",        "Plan details",        Some(MessagePhase::FinalAnswer),    );    handle_turn_completed(&mut chat, "live-turn-complete-2", /*duration_ms*/ None);    let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80);    assert!(        !duplicate_popup.contains(PLAN_IMPLEMENTATION_TITLE),        "expected no prompt for duplicate live completion, got {duplicate_popup:?}"    );}#[tokio::test]async fn plan_implementation_popup_skips_when_messages_queued() {    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);    let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)        .expect("expected plan collaboration mask");    chat.set_collaboration_mask(plan_mask);    chat.bottom_pane.set_task_running(/*running*/ true);    chat.queue_user_message("Queued message".into());    chat.on_task_complete(        Some("Plan details".to_string()),        /*duration_ms*/ None,        /*from_replay*/ false,    );    let popup = render_bottom_popup(&chat, /*width*/ 80);    assert!(        !popup.contains(PLAN_IMPLEMENTATION_TITLE),        "expected no plan popup with queued messages, got {popup:?}"    );}#[tokio::test]async fn plan_implementation_popup_skips_without_proposed_plan() {    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);    let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)        .expect("expected plan collaboration mask");    chat.set_collaboration_mask(plan_mask);    chat.on_task_started();    chat.on_plan_update(UpdatePlanArgs {        explanation: None,        plan: vec![PlanItemArg {            step: "First".to_string(),            status: StepStatus::Pending,        }],    });    chat.on_task_complete(        /*last_agent_message*/ None, /*duration_ms*/ None, /*from_replay*/ false,    );    let popup = render_bottom_popup(&chat, /*width*/ 80);    assert!(        !popup.contains(PLAN_IMPLEMENTATION_TITLE),        "expected no plan popup without proposed plan output, got {popup:?}"    );}#[tokio::test]async fn plan_implementation_popup_shows_after_proposed_plan_output() {    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);    let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)        .expect("expected plan collaboration mask");    chat.set_collaboration_mask(plan_mask);    chat.on_task_started();    chat.on_plan_delta("- Step 1\n- Step 2\n".to_string());    chat.on_plan_item_completed("- Step 1\n- Step 2\n".to_string());    chat.on_task_complete(        /*last_agent_message*/ None, /*duration_ms*/ None, /*from_replay*/ false,    );    let popup = render_bottom_popup(&chat, /*width*/ 80);    assert!(        popup.contains(PLAN_IMPLEMENTATION_TITLE),        "expected plan popup after proposed plan output, got {popup:?}"    );}#[tokio::test]async fn plan_implementation_popup_skips_when_steer_follows_proposed_plan() {    let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await;    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);    let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)        .expect("expected plan collaboration mask");    chat.set_collaboration_mask(plan_mask);    chat.thread_id = Some(ThreadId::new());    chat.on_task_started();    chat.on_plan_item_completed(        "- Step 1- Step 2"        .to_string(),    );    chat.bottom_pane        .set_composer_text("Please continue.".to_string(), Vec::new(), Vec::new());    chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));    match next_submit_op(&mut op_rx) {        Op::UserTurn { items, .. } => assert_eq!(            items,            vec![UserInput::Text {                text: "Please continue.".to_string(),                text_elements: Vec::new(),            }]        ),        other => panic!("expected Op::UserTurn, got {other:?}"),    }    complete_user_message(&mut chat, "user-1", "Please continue.");    chat.on_task_complete(        /*last_agent_message*/ None, /*duration_ms*/ None, /*from_replay*/ false,    );    let popup = render_bottom_popup(&chat, /*width*/ 80);    assert!(        !popup.contains(PLAN_IMPLEMENTATION_TITLE),        "expected no plan popup after a steer follows the plan, got {popup:?}"    );}#[tokio::test]async fn plan_implementation_popup_shows_after_new_plan_follows_steer() {    let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await;    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);    let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)        .expect("expected plan collaboration mask");    chat.set_collaboration_mask(plan_mask);    chat.thread_id = Some(ThreadId::new());    chat.on_task_started();    chat.on_plan_item_completed(        "- Initial plan"        .to_string(),    );    chat.bottom_pane        .set_composer_text("Please revise.".to_string(), Vec::new(), Vec::new());    chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));    match next_submit_op(&mut op_rx) {        Op::UserTurn { items, .. } => assert_eq!(            items,            vec![UserInput::Text {                text: "Please revise.".to_string(),                text_elements: Vec::new(),            }]        ),        other => panic!("expected Op::UserTurn, got {other:?}"),    }    complete_user_message(&mut chat, "user-1", "Please revise.");    chat.on_plan_item_completed(        "- Revised plan"        .to_string(),    );    chat.on_task_complete(        /*last_agent_message*/ None, /*duration_ms*/ None, /*from_replay*/ false,    );    let popup = render_bottom_popup(&chat, /*width*/ 80);    assert!(        popup.contains(PLAN_IMPLEMENTATION_TITLE),        "expected plan popup after a newer plan follows the steer, got {popup:?}"    );}#[tokio::test]async fn plan_implementation_popup_skips_when_rate_limit_prompt_pending() {    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;    chat.has_chatgpt_account = true;    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);    let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)        .expect("expected plan collaboration mask");    chat.set_collaboration_mask(plan_mask);    chat.on_task_started();    chat.on_plan_update(UpdatePlanArgs {        explanation: None,        plan: vec![PlanItemArg {            step: "First".to_string(),            status: StepStatus::Pending,        }],    });    chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 92.0)));    chat.on_task_complete(        /*last_agent_message*/ None, /*duration_ms*/ None, /*from_replay*/ false,    );    let popup = render_bottom_popup(&chat, /*width*/ 80);    assert!(        popup.contains("Approaching rate limits"),        "expected rate limit popup, got {popup:?}"    );    assert!(        !popup.contains(PLAN_IMPLEMENTATION_TITLE),        "expected plan popup to be skipped, got {popup:?}"    );}#[tokio::test]async fn plan_completion_restores_status_indicator_after_streaming_plan_output() {    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);    let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)        .expect("expected plan collaboration mask");    chat.set_collaboration_mask(plan_mask);    chat.on_task_started();    assert_eq!(chat.bottom_pane.status_indicator_visible(), true);    chat.on_plan_delta("- Step 1\n".to_string());    chat.on_commit_tick();    drain_insert_history(&mut rx);    assert_eq!(chat.bottom_pane.status_indicator_visible(), false);    assert_eq!(chat.bottom_pane.is_task_running(), true);    chat.on_plan_item_completed("- Step 1\n".to_string());    assert_eq!(chat.bottom_pane.status_indicator_visible(), true);    assert_eq!(chat.bottom_pane.is_task_running(), true);}#[tokio::test]async fn submit_user_message_queues_while_compaction_turn_is_running() {    let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;    let thread_id = ThreadId::new();    chat.thread_id = Some(thread_id);    chat.handle_server_notification(        ServerNotification::TurnStarted(TurnStartedNotification {            thread_id: thread_id.to_string(),            turn: AppServerTurn {                id: "turn-1".to_string(),                items_view: codex_app_server_protocol::TurnItemsView::Full,                items: Vec::new(),                status: AppServerTurnStatus::InProgress,                error: None,                started_at: Some(0),                completed_at: None,                duration_ms: None,            },        }),        /*replay_kind*/ None,    );    chat.submit_user_message(UserMessage::from("queued while compacting"));    assert_eq!(chat.input_queue.pending_steers.len(), 1);    match next_submit_op(&mut op_rx) {        Op::UserTurn { items, .. } => assert_eq!(            items,            vec![UserInput::Text {                text: "queued while compacting".to_string(),                text_elements: Vec::new(),            }]        ),        other => panic!("expected running-turn compact steer submit, got {other:?}"),    }    handle_error(        &mut chat,        "cannot steer a compact turn",        Some(CodexErrorInfo::ActiveTurnNotSteerable {            turn_kind: NonSteerableTurnKind::Compact,        }),    );    assert!(chat.input_queue.pending_steers.is_empty());    assert_eq!(        chat.queued_user_message_texts(),        vec!["queued while compacting"]    );    chat.handle_server_notification(        ServerNotification::TurnCompleted(TurnCompletedNotification {            thread_id: thread_id.to_string(),            turn: AppServerTurn {                id: "turn-1".to_string(),                items_view: codex_app_server_protocol::TurnItemsView::Full,                items: Vec::new(),                status: AppServerTurnStatus::Completed,                error: None,                started_at: None,                completed_at: Some(0),                duration_ms: None,            },        }),        /*replay_kind*/ None,    );    match next_submit_op(&mut op_rx) {        Op::UserTurn { items, .. } => assert_eq!(            items,            vec![UserInput::Text {                text: "queued while compacting".to_string(),                text_elements: Vec::new(),            }]        ),        other => panic!("expected queued compact follow-up Op::UserTurn, got {other:?}"),    }}#[tokio::test(flavor = "multi_thread")]async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() {    let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;    let thread_id = ThreadId::new();    let rollout_file = NamedTempFile::new().unwrap();    let configured = crate::session_state::ThreadSessionState {        thread_id,        forked_from_id: None,        fork_parent_title: None,        thread_name: None,        model: "test-model".to_string(),        model_provider_id: "test-provider".to_string(),        service_tier: None,        approval_policy: AskForApproval::Never,        approvals_reviewer: ApprovalsReviewer::User,        permission_profile: PermissionProfile::read_only(),        active_permission_profile: None,        cwd: test_path_buf("/home/user/project").abs(),        runtime_workspace_roots: Vec::new(),        instruction_source_paths: Vec::new(),        reasoning_effort: Some(ReasoningEffortConfig::default()),        collaboration_mode: None,        personality: None,        message_history: None,        network_proxy: None,        rollout_path: Some(rollout_file.path().to_path_buf()),    };    chat.handle_thread_session(configured);    chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);    chat.bottom_pane        .set_plugin_mentions(Some(vec![codex_plugin::PluginCapabilitySummary {            config_name: "sample@test".to_string(),            display_name: "Sample Plugin".to_string(),            description: None,            has_skills: true,            mcp_server_names: Vec::new(),            app_connector_ids: Vec::new(),        }]));    chat.submit_user_message(UserMessage {        text: "$sample".to_string(),        local_images: Vec::new(),        remote_image_urls: Vec::new(),        text_elements: Vec::new(),        mention_bindings: vec![MentionBinding {            sigil: '$',            mention: "sample".to_string(),            path: "plugin://sample@test".to_string(),        }],    });    let Op::UserTurn { items, .. } = next_submit_op(&mut op_rx) else {        panic!("expected Op::UserTurn");    };    assert_eq!(        items,        vec![            UserInput::Text {                text: "$sample".to_string(),                text_elements: Vec::new(),            },            UserInput::Mention {                name: "Sample Plugin".to_string(),                path: "plugin://sample@test".to_string(),            },        ]    );}#[tokio::test]async fn enter_submits_when_plan_stream_is_not_active() {    let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;    chat.thread_id = Some(ThreadId::new());    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);    let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)        .expect("expected plan collaboration mask");    chat.set_collaboration_mask(plan_mask);    chat.on_task_started();    chat.bottom_pane        .set_composer_text("submitted immediately".to_string(), Vec::new(), Vec::new());    chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));    assert!(chat.input_queue.queued_user_messages.is_empty());    match next_submit_op(&mut op_rx) {        Op::UserTurn {            personality: Some(Personality::Pragmatic),            ..        } => {}        other => panic!("expected Op::UserTurn, got {other:?}"),    }}#[tokio::test]async fn collab_mode_shift_tab_cycles_only_when_idle() {    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;    let initial = chat.current_collaboration_mode().clone();    chat.handle_key_event(KeyEvent::from(KeyCode::BackTab));    assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);    assert_eq!(chat.current_collaboration_mode(), &initial);    chat.handle_key_event(KeyEvent::from(KeyCode::BackTab));    assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Default);    assert_eq!(chat.current_collaboration_mode(), &initial);    chat.on_task_started();    let before = chat.active_collaboration_mode_kind();    chat.handle_key_event(KeyEvent::from(KeyCode::BackTab));    assert_eq!(chat.active_collaboration_mode_kind(), before);}#[tokio::test]async fn mode_switch_surfaces_model_change_notification_when_effective_model_changes() {    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);    let default_model = chat.current_model().to_string();    let mut plan_mask =        collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)            .expect("expected plan collaboration mode");    plan_mask.model = Some("gpt-5.4-mini".to_string());    chat.set_collaboration_mask(plan_mask);    let plan_messages = drain_insert_history(&mut rx)        .iter()        .map(|lines| lines_to_single_string(lines))        .collect::<Vec<_>>()        .join("\n");    assert!(        plan_messages.contains("Model changed to gpt-5.4-mini medium for Plan mode."),        "expected Plan-mode model switch notice, got: {plan_messages:?}"    );    let default_mask = collaboration_modes::default_mask(chat.model_catalog.as_ref())        .expect("expected default collaboration mode");    chat.set_collaboration_mask(default_mask);    let default_messages = drain_insert_history(&mut rx)        .iter()        .map(|lines| lines_to_single_string(lines))        .collect::<Vec<_>>()        .join("\n");    let expected_default_message =        format!("Model changed to {default_model} default for Default mode.");    assert!(        default_messages.contains(&expected_default_message),        "expected Default-mode model switch notice, got: {default_messages:?}"    );}#[tokio::test]async fn mode_switch_surfaces_reasoning_change_notification_when_model_stays_same() {    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);    chat.set_reasoning_effort(Some(ReasoningEffortConfig::High));    let plan_mask = collaboration_modes::plan_mask(chat.model_catalog.as_ref())        .expect("expected plan collaboration mode");    chat.set_collaboration_mask(plan_mask);    let plan_messages = drain_insert_history(&mut rx)        .iter()        .map(|lines| lines_to_single_string(lines))        .collect::<Vec<_>>()        .join("\n");    assert!(        plan_messages.contains("Model changed to gpt-5.3-codex medium for Plan mode."),        "expected reasoning-change notice in Plan mode, got: {plan_messages:?}"    );}#[tokio::test]async fn plan_slash_command_switches_to_plan_mode() {    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);    let initial = chat.current_collaboration_mode().clone();    chat.dispatch_command(SlashCommand::Plan);    while let Ok(event) = rx.try_recv() {        assert!(            matches!(event, AppEvent::InsertHistoryCell(_)),            "plan should not emit a non-history app event: {event:?}"        );    }    assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);    assert_eq!(chat.current_collaboration_mode(), &initial);}#[tokio::test]async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() {    let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);    let configured = crate::session_state::ThreadSessionState {        thread_id: ThreadId::new(),        forked_from_id: None,        fork_parent_title: None,        thread_name: None,        model: "test-model".to_string(),        model_provider_id: "test-provider".to_string(),        service_tier: None,        approval_policy: AskForApproval::Never,        approvals_reviewer: ApprovalsReviewer::User,        permission_profile: PermissionProfile::read_only(),        active_permission_profile: None,        cwd: test_path_buf("/home/user/project").abs(),        runtime_workspace_roots: Vec::new(),        instruction_source_paths: Vec::new(),        reasoning_effort: Some(ReasoningEffortConfig::default()),        collaboration_mode: None,        personality: None,        message_history: None,        network_proxy: None,        rollout_path: None,    };    chat.handle_thread_session(configured);    chat.bottom_pane        .set_composer_text("/plan build the plan".to_string(), Vec::new(), Vec::new());    chat.handle_key_event(KeyEvent::from(KeyCode::Enter));    let items = match next_submit_op(&mut op_rx) {        Op::UserTurn { items, .. } => items,        other => panic!("expected Op::UserTurn, got {other:?}"),    };    assert_eq!(items.len(), 1);    assert_eq!(        items[0],        UserInput::Text {            text: "build the plan".to_string(),            text_elements: Vec::new(),        }    );    assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);}#[tokio::test]async fn collaboration_modes_defaults_to_code_on_startup() {    let chat = make_startup_chat_with_cli_overrides(vec![(        "features.collaboration_modes".to_string(),        TomlValue::Boolean(true),    )])    .await;    assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Default);    assert_eq!(        chat.current_model(),        get_model_offline_for_tests(chat.config.model.as_deref())    );}#[tokio::test]async fn vim_mode_default_disabled_starts_composer_in_insert_mode() {    let chat = make_startup_chat_with_cli_overrides(Vec::new()).await;    assert!(!chat.bottom_pane.composer_is_vim_enabled());}#[tokio::test]async fn vim_mode_default_enabled_starts_composer_in_normal_mode() {    let chat = make_startup_chat_with_cli_overrides(vec![(        "tui.vim_mode_default".to_string(),        TomlValue::Boolean(true),    )])    .await;    assert!(chat.bottom_pane.composer_is_vim_enabled());    assert!(chat.composer_is_empty());    let mut chat = chat;    chat.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));    assert_eq!(chat.bottom_pane.composer_text(), "");}async fn make_startup_chat_with_cli_overrides(    cli_overrides: Vec<(String, TomlValue)>,) -> ChatWidget {    let codex_home = tempdir().expect("tempdir");    let cfg = ConfigBuilder::default()        .codex_home(codex_home.path().to_path_buf())        .cli_overrides(cli_overrides)        .build()        .await        .expect("config");    let resolved_model = get_model_offline_for_tests(cfg.model.as_deref());    let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str());    let init = ChatWidgetInit {        config: cfg.clone(),        frame_requester: FrameRequester::test_dummy(),        app_event_tx: AppEventSender::new(unbounded_channel::<AppEvent>().0),        workspace_command_runner: None,        initial_user_message: None,        enhanced_keys_supported: false,        has_chatgpt_account: false,        has_codex_backend_auth: false,        model_catalog: test_model_catalog(&cfg),        feedback: codex_feedback::CodexFeedback::new(),        is_first_run: true,        status_account_display: None,        runtime_model_provider_base_url: None,        initial_plan_type: None,        model: Some(resolved_model),        startup_tooltip_override: None,        status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),        terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),        session_telemetry,    };    ChatWidget::new_with_app_event(init)}#[tokio::test]async fn set_model_updates_active_collaboration_mask() {    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await;    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);    let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)        .expect("expected plan collaboration mask");    chat.set_collaboration_mask(plan_mask);    chat.set_model("gpt-5.4-mini");    assert_eq!(chat.current_model(), "gpt-5.4-mini");    assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);}#[tokio::test]async fn set_reasoning_effort_updates_active_collaboration_mask() {    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await;    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);    let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)        .expect("expected plan collaboration mask");    chat.set_collaboration_mask(plan_mask);    chat.set_reasoning_effort(/*effort*/ None);    assert_eq!(        chat.current_reasoning_effort(),        Some(ReasoningEffortConfig::Medium)    );    assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);}#[tokio::test]async fn set_reasoning_effort_does_not_override_active_plan_override() {    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await;    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);    chat.set_plan_mode_reasoning_effort(Some(ReasoningEffortConfig::High));    let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)        .expect("expected plan collaboration mask");    chat.set_collaboration_mask(plan_mask);    chat.set_reasoning_effort(Some(ReasoningEffortConfig::Low));    assert_eq!(        chat.current_reasoning_effort(),        Some(ReasoningEffortConfig::High)    );    assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);}#[tokio::test]async fn collab_mode_is_sent_after_enabling() {    let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;    chat.thread_id = Some(ThreadId::new());    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);    chat.bottom_pane        .set_composer_text("hello".to_string(), Vec::new(), Vec::new());    chat.handle_key_event(KeyEvent::from(KeyCode::Enter));    match next_submit_op(&mut op_rx) {        Op::UserTurn {            collaboration_mode:                Some(CollaborationMode {                    mode: ModeKind::Default,                    ..                }),            personality: Some(Personality::Pragmatic),            ..        } => {}        other => {            panic!("expected Op::UserTurn, got {other:?}")        }    }}#[tokio::test]async fn collab_mode_applies_default_preset() {    let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;    chat.thread_id = Some(ThreadId::new());    chat.bottom_pane        .set_composer_text("hello".to_string(), Vec::new(), Vec::new());    chat.handle_key_event(KeyEvent::from(KeyCode::Enter));    match next_submit_op(&mut op_rx) {        Op::UserTurn {            collaboration_mode:                Some(CollaborationMode {                    mode: ModeKind::Default,                    ..                }),            personality: Some(Personality::Pragmatic),            ..        } => {}        other => {            panic!("expected Op::UserTurn with default collaboration_mode, got {other:?}")        }    }    assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Default);    assert_eq!(chat.current_collaboration_mode().mode, ModeKind::Default);}#[tokio::test]async fn user_turn_includes_personality_from_config() {    let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;    chat.set_feature_enabled(Feature::Personality, /*enabled*/ true);    chat.thread_id = Some(ThreadId::new());    chat.set_model("gpt-5.3-codex");    chat.set_personality(Personality::Friendly);    chat.bottom_pane        .set_composer_text("hello".to_string(), Vec::new(), Vec::new());    chat.handle_key_event(KeyEvent::from(KeyCode::Enter));    match next_submit_op(&mut op_rx) {        Op::UserTurn {            personality: Some(Personality::Friendly),            ..        } => {}        other => panic!("expected Op::UserTurn with friendly personality, got {other:?}"),    }}#[tokio::test]async fn plan_update_renders_history_cell() {    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;    let update = UpdatePlanArgs {        explanation: Some("Adapting plan".to_string()),        plan: vec![            PlanItemArg {                step: "Explore codebase".into(),                status: StepStatus::Completed,            },            PlanItemArg {                step: "Implement feature".into(),                status: StepStatus::InProgress,            },            PlanItemArg {                step: "Write tests".into(),                status: StepStatus::Pending,            },        ],    };    chat.on_plan_update(update);    let cells = drain_insert_history(&mut rx);    assert!(!cells.is_empty(), "expected plan update cell to be sent");    let blob = lines_to_single_string(cells.last().unwrap());    assert!(        blob.contains("Updated Plan"),        "missing plan header: {blob:?}"    );    assert!(blob.contains("Explore codebase"));    assert!(blob.contains("Implement feature"));    assert!(blob.contains("Write tests"));}