From 3003f069cbe6de6d06d7c13fec83125c91805e85 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 20 Apr 2026 18:04:32 -0700 Subject: [PATCH] tui: colocate app module tests --- codex-rs/tui/src/app.rs | 951 +----------------- codex-rs/tui/src/app/side.rs | 546 ++++++++++ codex-rs/tui/src/app/test_support.rs | 405 ++++++++ codex-rs/tui/src/multi_agents.rs | 54 + ...gents__tests__agent_picker_item_name.snap} | 2 +- 5 files changed, 1009 insertions(+), 949 deletions(-) create mode 100644 codex-rs/tui/src/app/test_support.rs rename codex-rs/tui/src/snapshots/{codex_tui__app__tests__agent_picker_item_name.snap => codex_tui__multi_agents__tests__agent_picker_item_name.snap} (89%) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 15b3b87f24..8f4c7522ec 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -180,6 +180,8 @@ mod loaded_threads; mod pending_interactive_replay; mod replay_filter; mod side; +#[cfg(test)] +mod test_support; mod thread_session_state; use self::agent_navigation::AgentNavigationDirection; @@ -7007,9 +7009,9 @@ impl Drop for App { #[cfg(test)] mod tests { + use super::test_support::*; use super::*; use crate::app_backtrack::BacktrackSelection; - use crate::app_backtrack::BacktrackState; use crate::app_backtrack::user_count; use crate::chatwidget::ChatWidgetInit; @@ -7017,7 +7019,6 @@ mod tests { use crate::chatwidget::tests::make_chatwidget_manual_with_sender; use crate::chatwidget::tests::set_chatgpt_auth; use crate::chatwidget::tests::set_fast_mode_test_catalog; - use crate::file_search::FileSearchManager; use crate::history_cell::AgentMessageCell; use crate::history_cell::HistoryCell; use crate::history_cell::UserHistoryCell; @@ -7025,27 +7026,12 @@ mod tests { use crate::multi_agents::AgentPickerThreadEntry; use assert_matches::assert_matches; - use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; use codex_app_server_protocol::AdditionalFileSystemPermissions; use codex_app_server_protocol::AdditionalNetworkPermissions; use codex_app_server_protocol::AdditionalPermissionProfile; - use codex_app_server_protocol::AgentMessageDeltaNotification; - use codex_app_server_protocol::CommandExecutionRequestApprovalParams; use codex_app_server_protocol::ConfigWarningNotification; - use codex_app_server_protocol::HookCompletedNotification; - use codex_app_server_protocol::HookEventName as AppServerHookEventName; - use codex_app_server_protocol::HookExecutionMode as AppServerHookExecutionMode; - use codex_app_server_protocol::HookHandlerType as AppServerHookHandlerType; - use codex_app_server_protocol::HookOutputEntry as AppServerHookOutputEntry; - use codex_app_server_protocol::HookOutputEntryKind as AppServerHookOutputEntryKind; - use codex_app_server_protocol::HookRunStatus as AppServerHookRunStatus; - use codex_app_server_protocol::HookRunSummary as AppServerHookRunSummary; - use codex_app_server_protocol::HookScope as AppServerHookScope; - use codex_app_server_protocol::HookStartedNotification; use codex_app_server_protocol::JSONRPCErrorError; - use codex_app_server_protocol::McpServerStartupState; - use codex_app_server_protocol::McpServerStatusUpdatedNotification; use codex_app_server_protocol::NetworkApprovalContext as AppServerNetworkApprovalContext; use codex_app_server_protocol::NetworkApprovalProtocol as AppServerNetworkApprovalProtocol; use codex_app_server_protocol::NetworkPolicyAmendment as AppServerNetworkPolicyAmendment; @@ -7057,21 +7043,13 @@ mod tests { use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::Thread; - use codex_app_server_protocol::ThreadClosedNotification; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadStartedNotification; - use codex_app_server_protocol::ThreadTokenUsage; - use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; - use codex_app_server_protocol::TokenUsageBreakdown; - use codex_app_server_protocol::ToolRequestUserInputParams; use codex_app_server_protocol::Turn; - use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnError as AppServerTurnError; - use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput as AppServerUserInput; use codex_app_server_protocol::WarningNotification; - use codex_otel::SessionTelemetry; use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::CollaborationModeMask; @@ -7096,23 +7074,17 @@ mod tests { use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; - use codex_utils_absolute_path::AbsolutePathBuf; use crossterm::event::KeyModifiers; use insta::assert_snapshot; use pretty_assertions::assert_eq; use ratatui::prelude::Line; use std::path::PathBuf; use std::sync::Arc; - use std::sync::atomic::AtomicBool; use tempfile::tempdir; use tokio::time; mod model_catalog; - fn test_absolute_path(path: &str) -> AbsolutePathBuf { - AbsolutePathBuf::try_from(PathBuf::from(path)).expect("absolute test path") - } - #[test] fn hide_cli_only_plugin_marketplaces_removes_openai_bundled() { let mut response = PluginListResponse { @@ -10001,575 +9973,6 @@ guardian_approval = true Ok(()) } - #[test] - fn agent_picker_item_name_snapshot() { - let thread_id = - ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread id"); - let snapshot = [ - format!( - "{} | {}", - format_agent_picker_item_name( - Some("Robie"), - Some("explorer"), - /*is_primary*/ true - ), - thread_id - ), - format!( - "{} | {}", - format_agent_picker_item_name( - Some("Robie"), - Some("explorer"), - /*is_primary*/ false - ), - thread_id - ), - format!( - "{} | {}", - format_agent_picker_item_name( - Some("Robie"), - /*agent_role*/ None, - /*is_primary*/ false - ), - thread_id - ), - format!( - "{} | {}", - format_agent_picker_item_name( - /*agent_nickname*/ None, - Some("explorer"), - /*is_primary*/ false - ), - thread_id - ), - format!( - "{} | {}", - format_agent_picker_item_name( - /*agent_nickname*/ None, /*agent_role*/ None, - /*is_primary*/ false - ), - thread_id - ), - ] - .join("\n"); - assert_snapshot!("agent_picker_item_name", snapshot); - } - - #[tokio::test] - async fn side_fork_config_is_ephemeral_and_appends_developer_guardrails() { - let mut app = make_test_app().await; - app.config.developer_instructions = Some("Existing developer policy.".to_string()); - let original_approval_policy = app.config.permissions.approval_policy.value(); - let original_sandbox_policy = app.config.permissions.sandbox_policy.get().clone(); - - let fork_config = app.side_fork_config(); - - assert!(fork_config.ephemeral); - assert_eq!( - fork_config.permissions.approval_policy.value(), - original_approval_policy - ); - assert_eq!( - fork_config.permissions.sandbox_policy.get(), - &original_sandbox_policy - ); - let developer_instructions = fork_config - .developer_instructions - .as_deref() - .expect("side developer instructions"); - assert!(developer_instructions.contains("Existing developer policy.")); - assert!( - developer_instructions.contains("You are in a side conversation, not the main thread.") - ); - assert!( - developer_instructions - .contains("inherited fork history is provided only as reference context") - ); - assert!(developer_instructions.contains( - "Only instructions submitted after the side-conversation boundary are active" - )); - assert!(developer_instructions.contains("Do not continue, execute, or complete any task")); - assert!( - developer_instructions - .contains("External tools may be available according to this thread's current") - ); - assert!( - developer_instructions - .contains("Any MCP or external tool calls or outputs visible in the inherited") - ); - assert!(developer_instructions.contains("non-mutating inspection")); - assert!(developer_instructions.contains("Do not modify files")); - assert!(developer_instructions.contains("Do not request escalated permissions")); - assert!(app.transcript_cells.is_empty()); - } - - #[test] - fn side_boundary_prompt_marks_inherited_history_reference_only() { - let item = App::side_boundary_prompt_item(); - let codex_protocol::models::ResponseItem::Message { role, content, .. } = item else { - panic!("expected hidden side boundary prompt to be a user message"); - }; - assert_eq!(role, "user"); - let [codex_protocol::models::ContentItem::InputText { text }] = content.as_slice() else { - panic!("expected hidden side boundary prompt text"); - }; - assert!(text.contains("Side conversation boundary.")); - assert!(text.contains("Everything before this boundary is inherited history")); - assert!(text.contains("It is not your current task.")); - assert!(text.contains("Only messages submitted after this boundary are active")); - assert!(text.contains("Do not continue, execute, or complete")); - assert!(text.contains("separate from the main thread")); - assert!( - text.contains("External tools may be available according to this thread's current") - ); - assert!(text.contains("Any tool calls or outputs visible before this boundary happened")); - assert!(text.contains("Do not modify files")); - } - - #[test] - fn side_return_shortcuts_match_esc_and_ctrl_c() { - assert!(side_return_shortcut_matches(KeyEvent::new( - KeyCode::Esc, - KeyModifiers::NONE, - ))); - assert!(side_return_shortcut_matches(KeyEvent::new_with_kind( - KeyCode::Esc, - KeyModifiers::NONE, - KeyEventKind::Repeat, - ))); - assert!(side_return_shortcut_matches(KeyEvent::new( - KeyCode::Char('c'), - KeyModifiers::CONTROL, - ))); - assert!(side_return_shortcut_matches(KeyEvent::new( - KeyCode::Char('C'), - KeyModifiers::CONTROL, - ))); - assert!(!side_return_shortcut_matches(KeyEvent::new( - KeyCode::Char('d'), - KeyModifiers::CONTROL, - ))); - assert!(!side_return_shortcut_matches(KeyEvent::new_with_kind( - KeyCode::Esc, - KeyModifiers::NONE, - KeyEventKind::Release, - ))); - } - - #[tokio::test] - async fn side_start_block_message_tracks_open_side_conversation() { - let mut app = make_test_app().await; - assert_eq!( - app.side_start_block_message(), - Some("'/side' is unavailable until the main thread is ready.") - ); - - app.primary_thread_id = Some(ThreadId::new()); - assert_eq!(app.side_start_block_message(), None); - - let parent_thread_id = ThreadId::new(); - let side_thread_id = ThreadId::new(); - app.side_threads - .insert(side_thread_id, SideThreadState::new(parent_thread_id)); - - assert_eq!( - app.side_start_block_message(), - Some( - "A side conversation is already open. Press Esc to return before starting another." - ) - ); - - app.side_threads.remove(&side_thread_id); - assert_eq!(app.side_start_block_message(), None); - } - - #[tokio::test] - async fn side_parent_status_tracks_parent_turn_lifecycle() -> Result<()> { - let mut app = make_test_app().await; - let parent_thread_id = ThreadId::new(); - let side_thread_id = ThreadId::new(); - app.primary_thread_id = Some(parent_thread_id); - app.active_thread_id = Some(side_thread_id); - app.side_threads - .insert(side_thread_id, SideThreadState::new(parent_thread_id)); - - app.enqueue_thread_notification( - parent_thread_id, - turn_completed_notification(parent_thread_id, "turn-1", TurnStatus::Completed), - ) - .await?; - assert_eq!( - app.side_threads - .get(&side_thread_id) - .and_then(|state| state.parent_status), - Some(SideParentStatus::Finished) - ); - - app.enqueue_thread_notification( - parent_thread_id, - turn_started_notification(parent_thread_id, "turn-2"), - ) - .await?; - assert_eq!( - app.side_threads - .get(&side_thread_id) - .and_then(|state| state.parent_status), - None - ); - - app.enqueue_thread_notification( - parent_thread_id, - turn_completed_notification(parent_thread_id, "turn-2", TurnStatus::Failed), - ) - .await?; - assert_eq!( - app.side_threads - .get(&side_thread_id) - .and_then(|state| state.parent_status), - Some(SideParentStatus::Failed) - ); - - Ok(()) - } - - #[tokio::test] - async fn side_parent_status_prioritizes_input_over_approval() -> Result<()> { - let mut app = make_test_app().await; - let parent_thread_id = ThreadId::new(); - let side_thread_id = ThreadId::new(); - app.primary_thread_id = Some(parent_thread_id); - app.active_thread_id = Some(side_thread_id); - app.side_threads - .insert(side_thread_id, SideThreadState::new(parent_thread_id)); - - app.enqueue_thread_request( - parent_thread_id, - exec_approval_request( - parent_thread_id, - "turn-approval", - "call-approval", - /*approval_id*/ None, - ), - ) - .await?; - assert_eq!( - app.side_threads - .get(&side_thread_id) - .and_then(|state| state.parent_status), - Some(SideParentStatus::NeedsApproval) - ); - - app.enqueue_thread_request( - parent_thread_id, - request_user_input_request(parent_thread_id, "turn-input", "call-input"), - ) - .await?; - assert_eq!( - app.side_threads - .get(&side_thread_id) - .and_then(|state| state.parent_status), - Some(SideParentStatus::NeedsInput) - ); - - app.enqueue_thread_notification( - parent_thread_id, - ServerNotification::ServerRequestResolved( - codex_app_server_protocol::ServerRequestResolvedNotification { - thread_id: parent_thread_id.to_string(), - request_id: AppServerRequestId::Integer(2), - }, - ), - ) - .await?; - assert_eq!( - app.side_threads - .get(&side_thread_id) - .and_then(|state| state.parent_status), - Some(SideParentStatus::NeedsApproval) - ); - - app.enqueue_thread_notification( - parent_thread_id, - ServerNotification::ServerRequestResolved( - codex_app_server_protocol::ServerRequestResolvedNotification { - thread_id: parent_thread_id.to_string(), - request_id: AppServerRequestId::Integer(1), - }, - ), - ) - .await?; - assert_eq!( - app.side_threads - .get(&side_thread_id) - .and_then(|state| state.parent_status), - None - ); - - Ok(()) - } - - #[test] - fn side_start_error_message_explains_missing_first_prompt() { - let err = color_eyre::eyre::eyre!( - "thread/fork failed during TUI bootstrap: thread/fork failed: no rollout found for thread id 019da1a1-bed9-7a43-88a2-b49d43915021" - ); - - assert_eq!( - App::side_start_error_message(&err), - "'/side' is unavailable until the current conversation has started. Send a message first, then try /side again." - ); - } - - #[test] - fn side_start_error_message_uses_generic_start_wording() { - let err = color_eyre::eyre::eyre!("transport disconnected"); - - assert_eq!( - App::side_start_error_message(&err), - "Failed to start side conversation: transport disconnected" - ); - } - - #[tokio::test] - async fn side_thread_snapshot_hides_forked_parent_transcript() { - let parent_thread_id = ThreadId::new(); - let side_thread_id = ThreadId::new(); - let mut store = ThreadEventStore::new(/*capacity*/ 4); - let session = ThreadSessionState { - forked_from_id: Some(parent_thread_id), - ..test_thread_session(side_thread_id, test_path_buf("/tmp/side")) - }; - let parent_turn = test_turn( - "parent-turn", - TurnStatus::Completed, - vec![ThreadItem::UserMessage { - id: "parent-user".to_string(), - content: vec![AppServerUserInput::Text { - text: "parent prompt should stay hidden".to_string(), - text_elements: Vec::new(), - }], - }], - ); - - App::install_side_thread_snapshot(&mut store, session, vec![parent_turn]); - - let stored_session = store.session.as_ref().expect("side session"); - assert_eq!(stored_session.thread_id, side_thread_id); - assert_eq!(stored_session.forked_from_id, None); - assert_eq!(store.turns, Vec::::new()); - assert_eq!(store.active_turn_id(), None); - } - - #[tokio::test] - async fn side_thread_snapshot_does_not_refresh_from_fork_history() { - let mut app = make_test_app().await; - let parent_thread_id = ThreadId::new(); - let side_thread_id = ThreadId::new(); - app.side_threads - .insert(side_thread_id, SideThreadState::new(parent_thread_id)); - - let snapshot = ThreadEventSnapshot { - session: Some(ThreadSessionState { - rollout_path: None, - ..test_thread_session(side_thread_id, test_path_buf("/tmp/side")) - }), - turns: Vec::new(), - events: Vec::new(), - input_state: None, - }; - - assert!(!app.should_refresh_snapshot_session( - side_thread_id, - /*is_replay_only*/ false, - &snapshot - )); - } - - #[tokio::test] - async fn side_thread_snapshot_skips_session_header_preamble() { - let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; - while app_event_rx.try_recv().is_ok() {} - - let parent_thread_id = ThreadId::new(); - let side_thread_id = ThreadId::new(); - app.primary_thread_id = Some(parent_thread_id); - app.side_threads - .insert(side_thread_id, SideThreadState::new(parent_thread_id)); - - let snapshot = ThreadEventSnapshot { - session: Some(ThreadSessionState { - forked_from_id: Some(parent_thread_id), - ..test_thread_session(side_thread_id, test_path_buf("/tmp/side")) - }), - turns: Vec::new(), - events: Vec::new(), - input_state: None, - }; - - app.replay_thread_snapshot(snapshot, /*resume_restored_queue*/ false); - - let mut rendered_cells = Vec::new(); - while let Ok(event) = app_event_rx.try_recv() { - if let AppEvent::InsertHistoryCell(cell) = event { - rendered_cells.push(lines_to_single_string(&cell.display_lines(/*width*/ 120))); - } - } - assert_eq!(app.chat_widget.thread_id(), Some(side_thread_id)); - assert_eq!(rendered_cells, Vec::::new()); - assert_eq!( - app.chat_widget.active_cell_transcript_lines(/*width*/ 120), - None - ); - } - - #[tokio::test] - async fn side_thread_ignores_global_mcp_startup_notifications() { - let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; - while app_event_rx.try_recv().is_ok() {} - let app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) - .await - .expect("embedded app server"); - let parent_thread_id = ThreadId::new(); - let side_thread_id = ThreadId::new(); - app.primary_thread_id = Some(parent_thread_id); - app.active_thread_id = Some(side_thread_id); - app.side_threads - .insert(side_thread_id, SideThreadState::new(parent_thread_id)); - app.sync_side_thread_ui(); - - app.handle_app_server_event( - &app_server, - codex_app_server_client::AppServerEvent::ServerNotification( - ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { - name: "sentry".to_string(), - status: McpServerStartupState::Failed, - error: Some("sentry is not logged in".to_string()), - }), - ), - ) - .await; - - assert!(app_event_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn side_restore_user_message_puts_inline_question_back_in_composer() { - let mut app = make_test_app().await; - let user_message = crate::chatwidget::UserMessage::from("side question"); - - app.restore_side_user_message(Some(user_message)); - - assert_eq!( - app.chat_widget.composer_text_with_pending(), - "side question" - ); - } - - #[tokio::test] - async fn side_discard_selection_keeps_current_side_thread() { - let mut app = make_test_app().await; - let parent_thread_id = ThreadId::new(); - let side_thread_id = ThreadId::new(); - app.active_thread_id = Some(side_thread_id); - app.side_threads - .insert(side_thread_id, SideThreadState::new(parent_thread_id)); - - assert_eq!( - app.side_thread_to_discard_after_switch(side_thread_id), - None - ); - assert_eq!( - app.side_thread_to_discard_after_switch(parent_thread_id), - Some(side_thread_id) - ); - } - - #[tokio::test] - async fn discard_side_thread_removes_agent_navigation_entry() -> Result<()> { - let mut app = make_test_app().await; - let mut app_server = - crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; - let mut side_config = app.chat_widget.config_ref().clone(); - side_config.ephemeral = true; - let started = app_server.start_thread(&side_config).await?; - let side_thread_id = started.session.thread_id; - app.side_threads - .insert(side_thread_id, SideThreadState::new(ThreadId::new())); - app.agent_navigation.upsert( - side_thread_id, - Some("Side".to_string()), - Some("side".to_string()), - /*is_closed*/ false, - ); - - assert!( - app.discard_side_thread(&mut app_server, side_thread_id) - .await - ); - - assert_eq!(app.agent_navigation.get(&side_thread_id), None); - assert!(!app.side_threads.contains_key(&side_thread_id)); - Ok(()) - } - - #[tokio::test] - async fn discard_side_thread_keeps_local_state_when_server_close_fails() -> Result<()> { - let mut app = make_test_app().await; - let mut app_server = - crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; - let parent_thread_id = ThreadId::new(); - let side_thread_id = ThreadId::new(); - app.active_thread_id = Some(side_thread_id); - app.side_threads - .insert(side_thread_id, SideThreadState::new(parent_thread_id)); - app.agent_navigation.upsert( - side_thread_id, - Some("Side".to_string()), - Some("side".to_string()), - /*is_closed*/ false, - ); - - assert!( - !app.discard_side_thread(&mut app_server, side_thread_id) - .await - ); - - assert_eq!(app.active_thread_id, Some(side_thread_id)); - assert_eq!( - app.side_threads - .get(&side_thread_id) - .map(|state| state.parent_thread_id), - Some(parent_thread_id) - ); - assert!(app.agent_navigation.get(&side_thread_id).is_some()); - Ok(()) - } - - #[tokio::test] - async fn discard_closed_side_thread_removes_local_state_without_server_rpc() { - let mut app = make_test_app().await; - let parent_thread_id = ThreadId::new(); - let side_thread_id = ThreadId::new(); - app.active_thread_id = Some(side_thread_id); - app.side_threads - .insert(side_thread_id, SideThreadState::new(parent_thread_id)); - app.thread_event_channels - .insert(side_thread_id, ThreadEventChannel::new(/*capacity*/ 4)); - app.agent_navigation.upsert( - side_thread_id, - Some("Side".to_string()), - Some("side".to_string()), - /*is_closed*/ false, - ); - - app.discard_closed_side_thread(side_thread_id).await; - - assert_eq!(app.active_thread_id, None); - assert!(!app.side_threads.contains_key(&side_thread_id)); - assert!(!app.thread_event_channels.contains_key(&side_thread_id)); - assert_eq!(app.agent_navigation.get(&side_thread_id), None); - } - #[tokio::test] async fn active_non_primary_shutdown_target_returns_none_for_non_shutdown_event() -> Result<()> { @@ -10815,324 +10218,6 @@ guardian_approval = true assert_snapshot!("clear_ui_header_fast_status_fast_capable_models", rendered); } - async fn make_test_app() -> App { - let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await; - let config = chat_widget.config_ref().clone(); - let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone()); - let model = crate::legacy_core::test_support::get_model_offline(config.model.as_deref()); - let session_telemetry = test_session_telemetry(&config, model.as_str()); - - App { - model_catalog: chat_widget.model_catalog(), - session_telemetry, - app_event_tx, - chat_widget, - config, - active_profile: None, - cli_kv_overrides: Vec::new(), - harness_overrides: ConfigOverrides::default(), - runtime_approval_policy_override: None, - runtime_sandbox_policy_override: None, - file_search, - transcript_cells: Vec::new(), - overlay: None, - deferred_history_lines: Vec::new(), - has_emitted_history_lines: false, - enhanced_keys_supported: false, - commit_anim_running: Arc::new(AtomicBool::new(false)), - status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), - terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), - backtrack: BacktrackState::default(), - backtrack_render_pending: false, - feedback: codex_feedback::CodexFeedback::new(), - feedback_audience: FeedbackAudience::External, - environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), - remote_app_server_url: None, - remote_app_server_auth_token: None, - pending_update_action: None, - pending_shutdown_exit_thread_id: None, - windows_sandbox: WindowsSandboxState::default(), - thread_event_channels: HashMap::new(), - thread_event_listener_tasks: HashMap::new(), - agent_navigation: AgentNavigationState::default(), - side_threads: HashMap::new(), - active_thread_id: None, - active_thread_rx: None, - primary_thread_id: None, - last_subagent_backfill_attempt: None, - primary_session_configured: None, - pending_primary_events: VecDeque::new(), - pending_app_server_requests: PendingAppServerRequests::default(), - pending_plugin_enabled_writes: HashMap::new(), - } - } - - async fn make_test_app_with_channels() -> ( - App, - tokio::sync::mpsc::UnboundedReceiver, - tokio::sync::mpsc::UnboundedReceiver, - ) { - let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender().await; - let config = chat_widget.config_ref().clone(); - let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone()); - let model = crate::legacy_core::test_support::get_model_offline(config.model.as_deref()); - let session_telemetry = test_session_telemetry(&config, model.as_str()); - - ( - App { - model_catalog: chat_widget.model_catalog(), - session_telemetry, - app_event_tx, - chat_widget, - config, - active_profile: None, - cli_kv_overrides: Vec::new(), - harness_overrides: ConfigOverrides::default(), - runtime_approval_policy_override: None, - runtime_sandbox_policy_override: None, - file_search, - transcript_cells: Vec::new(), - overlay: None, - deferred_history_lines: Vec::new(), - has_emitted_history_lines: false, - enhanced_keys_supported: false, - commit_anim_running: Arc::new(AtomicBool::new(false)), - status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), - terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), - backtrack: BacktrackState::default(), - backtrack_render_pending: false, - feedback: codex_feedback::CodexFeedback::new(), - feedback_audience: FeedbackAudience::External, - environment_manager: Arc::new(EnvironmentManager::new( - /*exec_server_url*/ None, - )), - remote_app_server_url: None, - remote_app_server_auth_token: None, - pending_update_action: None, - pending_shutdown_exit_thread_id: None, - windows_sandbox: WindowsSandboxState::default(), - thread_event_channels: HashMap::new(), - thread_event_listener_tasks: HashMap::new(), - agent_navigation: AgentNavigationState::default(), - side_threads: HashMap::new(), - active_thread_id: None, - active_thread_rx: None, - primary_thread_id: None, - last_subagent_backfill_attempt: None, - primary_session_configured: None, - pending_primary_events: VecDeque::new(), - pending_app_server_requests: PendingAppServerRequests::default(), - pending_plugin_enabled_writes: HashMap::new(), - }, - rx, - op_rx, - ) - } - - fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState { - ThreadSessionState { - thread_id, - forked_from_id: None, - fork_parent_title: None, - thread_name: None, - model: "gpt-test".to_string(), - model_provider_id: "test-provider".to_string(), - service_tier: None, - approval_policy: AskForApproval::Never, - approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - cwd: cwd.abs(), - instruction_source_paths: Vec::new(), - reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, - network_proxy: None, - rollout_path: Some(PathBuf::new()), - } - } - - fn test_turn(turn_id: &str, status: TurnStatus, items: Vec) -> Turn { - Turn { - id: turn_id.to_string(), - items, - status, - error: None, - started_at: None, - completed_at: None, - duration_ms: None, - } - } - - fn turn_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification { - ServerNotification::TurnStarted(TurnStartedNotification { - thread_id: thread_id.to_string(), - turn: Turn { - started_at: Some(0), - ..test_turn(turn_id, TurnStatus::InProgress, Vec::new()) - }, - }) - } - - fn turn_completed_notification( - thread_id: ThreadId, - turn_id: &str, - status: TurnStatus, - ) -> ServerNotification { - ServerNotification::TurnCompleted(TurnCompletedNotification { - thread_id: thread_id.to_string(), - turn: Turn { - completed_at: Some(0), - duration_ms: Some(1), - ..test_turn(turn_id, status, Vec::new()) - }, - }) - } - - fn thread_closed_notification(thread_id: ThreadId) -> ServerNotification { - ServerNotification::ThreadClosed(ThreadClosedNotification { - thread_id: thread_id.to_string(), - }) - } - - fn token_usage_notification( - thread_id: ThreadId, - turn_id: &str, - model_context_window: Option, - ) -> ServerNotification { - ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification { - thread_id: thread_id.to_string(), - turn_id: turn_id.to_string(), - token_usage: ThreadTokenUsage { - total: TokenUsageBreakdown { - total_tokens: 10, - input_tokens: 4, - cached_input_tokens: 1, - output_tokens: 5, - reasoning_output_tokens: 0, - }, - last: TokenUsageBreakdown { - total_tokens: 10, - input_tokens: 4, - cached_input_tokens: 1, - output_tokens: 5, - reasoning_output_tokens: 0, - }, - model_context_window, - }, - }) - } - - fn hook_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification { - ServerNotification::HookStarted(HookStartedNotification { - thread_id: thread_id.to_string(), - turn_id: Some(turn_id.to_string()), - run: AppServerHookRunSummary { - id: "user-prompt-submit:0:/tmp/hooks.json".to_string(), - event_name: AppServerHookEventName::UserPromptSubmit, - handler_type: AppServerHookHandlerType::Command, - execution_mode: AppServerHookExecutionMode::Sync, - scope: AppServerHookScope::Turn, - source_path: test_path_buf("/tmp/hooks.json").abs(), - source: codex_app_server_protocol::HookSource::User, - display_order: 0, - status: AppServerHookRunStatus::Running, - status_message: Some("checking go-workflow input policy".to_string()), - started_at: 1, - completed_at: None, - duration_ms: None, - entries: Vec::new(), - }, - }) - } - - fn hook_completed_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification { - ServerNotification::HookCompleted(HookCompletedNotification { - thread_id: thread_id.to_string(), - turn_id: Some(turn_id.to_string()), - run: AppServerHookRunSummary { - id: "user-prompt-submit:0:/tmp/hooks.json".to_string(), - event_name: AppServerHookEventName::UserPromptSubmit, - handler_type: AppServerHookHandlerType::Command, - execution_mode: AppServerHookExecutionMode::Sync, - scope: AppServerHookScope::Turn, - source_path: test_path_buf("/tmp/hooks.json").abs(), - source: codex_app_server_protocol::HookSource::User, - display_order: 0, - status: AppServerHookRunStatus::Stopped, - status_message: Some("checking go-workflow input policy".to_string()), - started_at: 1, - completed_at: Some(11), - duration_ms: Some(10), - entries: vec![ - AppServerHookOutputEntry { - kind: AppServerHookOutputEntryKind::Warning, - text: "go-workflow must start from PlanMode".to_string(), - }, - AppServerHookOutputEntry { - kind: AppServerHookOutputEntryKind::Stop, - text: "prompt blocked".to_string(), - }, - ], - }, - }) - } - - fn agent_message_delta_notification( - thread_id: ThreadId, - turn_id: &str, - item_id: &str, - delta: &str, - ) -> ServerNotification { - ServerNotification::AgentMessageDelta(AgentMessageDeltaNotification { - thread_id: thread_id.to_string(), - turn_id: turn_id.to_string(), - item_id: item_id.to_string(), - delta: delta.to_string(), - }) - } - - fn exec_approval_request( - thread_id: ThreadId, - turn_id: &str, - item_id: &str, - approval_id: Option<&str>, - ) -> ServerRequest { - ServerRequest::CommandExecutionRequestApproval { - request_id: AppServerRequestId::Integer(1), - params: CommandExecutionRequestApprovalParams { - thread_id: thread_id.to_string(), - turn_id: turn_id.to_string(), - item_id: item_id.to_string(), - approval_id: approval_id.map(str::to_string), - reason: Some("needs approval".to_string()), - network_approval_context: None, - command: Some("echo hello".to_string()), - cwd: Some(test_path_buf("/tmp/project").abs()), - command_actions: None, - additional_permissions: None, - proposed_execpolicy_amendment: None, - proposed_network_policy_amendments: None, - available_decisions: None, - }, - } - } - - fn request_user_input_request( - thread_id: ThreadId, - turn_id: &str, - item_id: &str, - ) -> ServerRequest { - ServerRequest::ToolRequestUserInput { - request_id: AppServerRequestId::Integer(2), - params: ToolRequestUserInputParams { - thread_id: thread_id.to_string(), - turn_id: turn_id.to_string(), - item_id: item_id.to_string(), - questions: Vec::new(), - }, - } - } - #[test] fn thread_event_store_tracks_active_turn_lifecycle() { let mut store = ThreadEventStore::new(/*capacity*/ 8); @@ -11389,36 +10474,6 @@ guardian_approval = true panic!("expected UserTurn op, saw: {seen:?}"); } - fn lines_to_single_string(lines: &[Line<'_>]) -> String { - lines - .iter() - .map(|line| { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect::() - }) - .collect::>() - .join("\n") - } - - fn test_session_telemetry(config: &Config, model: &str) -> SessionTelemetry { - let model_info = - crate::legacy_core::test_support::construct_model_info_offline(model, config); - SessionTelemetry::new( - ThreadId::new(), - model, - model_info.slug.as_str(), - /*account_id*/ None, - /*account_email*/ None, - /*auth_mode*/ None, - "test_originator".to_string(), - /*log_user_prompts*/ false, - "test".to_string(), - SessionSource::Cli, - ) - } - fn app_enabled_in_effective_config(config: &Config, app_id: &str) -> Option { config .config_layer_stack diff --git a/codex-rs/tui/src/app/side.rs b/codex-rs/tui/src/app/side.rs index e951233c5d..6d40ceace2 100644 --- a/codex-rs/tui/src/app/side.rs +++ b/codex-rs/tui/src/app/side.rs @@ -568,3 +568,549 @@ impl App { Ok(AppRunControl::Continue) } } + +#[cfg(test)] +mod tests { + use super::super::test_support::exec_approval_request; + use super::super::test_support::lines_to_single_string; + use super::super::test_support::make_test_app; + use super::super::test_support::make_test_app_with_channels; + use super::super::test_support::request_user_input_request; + use super::super::test_support::test_thread_session; + use super::super::test_support::test_turn; + use super::super::test_support::turn_completed_notification; + use super::super::test_support::turn_started_notification; + use super::*; + use crate::app_event::AppEvent; + use crate::test_support::test_path_buf; + use codex_app_server_protocol::McpServerStartupState; + use codex_app_server_protocol::McpServerStatusUpdatedNotification; + use codex_app_server_protocol::RequestId as AppServerRequestId; + use codex_app_server_protocol::ServerNotification; + use codex_app_server_protocol::ThreadItem; + use codex_app_server_protocol::Turn; + use codex_app_server_protocol::TurnStatus; + use codex_app_server_protocol::UserInput as AppServerUserInput; + use codex_protocol::ThreadId; + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyEventKind; + use crossterm::event::KeyModifiers; + use pretty_assertions::assert_eq; + + #[tokio::test] + async fn side_fork_config_is_ephemeral_and_appends_developer_guardrails() { + let mut app = make_test_app().await; + app.config.developer_instructions = Some("Existing developer policy.".to_string()); + let original_approval_policy = app.config.permissions.approval_policy.value(); + let original_sandbox_policy = app.config.permissions.sandbox_policy.get().clone(); + + let fork_config = app.side_fork_config(); + + assert!(fork_config.ephemeral); + assert_eq!( + fork_config.permissions.approval_policy.value(), + original_approval_policy + ); + assert_eq!( + fork_config.permissions.sandbox_policy.get(), + &original_sandbox_policy + ); + let developer_instructions = fork_config + .developer_instructions + .as_deref() + .expect("side developer instructions"); + assert!(developer_instructions.contains("Existing developer policy.")); + assert!( + developer_instructions.contains("You are in a side conversation, not the main thread.") + ); + assert!( + developer_instructions + .contains("inherited fork history is provided only as reference context") + ); + assert!(developer_instructions.contains( + "Only instructions submitted after the side-conversation boundary are active" + )); + assert!(developer_instructions.contains("Do not continue, execute, or complete any task")); + assert!( + developer_instructions + .contains("External tools may be available according to this thread's current") + ); + assert!( + developer_instructions + .contains("Any MCP or external tool calls or outputs visible in the inherited") + ); + assert!(developer_instructions.contains("non-mutating inspection")); + assert!(developer_instructions.contains("Do not modify files")); + assert!(developer_instructions.contains("Do not request escalated permissions")); + assert!(app.transcript_cells.is_empty()); + } + + #[test] + fn side_boundary_prompt_marks_inherited_history_reference_only() { + let item = App::side_boundary_prompt_item(); + let codex_protocol::models::ResponseItem::Message { role, content, .. } = item else { + panic!("expected hidden side boundary prompt to be a user message"); + }; + assert_eq!(role, "user"); + let [codex_protocol::models::ContentItem::InputText { text }] = content.as_slice() else { + panic!("expected hidden side boundary prompt text"); + }; + assert!(text.contains("Side conversation boundary.")); + assert!(text.contains("Everything before this boundary is inherited history")); + assert!(text.contains("It is not your current task.")); + assert!(text.contains("Only messages submitted after this boundary are active")); + assert!(text.contains("Do not continue, execute, or complete")); + assert!(text.contains("separate from the main thread")); + assert!( + text.contains("External tools may be available according to this thread's current") + ); + assert!(text.contains("Any tool calls or outputs visible before this boundary happened")); + assert!(text.contains("Do not modify files")); + } + + #[test] + fn side_return_shortcuts_match_esc_and_ctrl_c() { + assert!(side_return_shortcut_matches(KeyEvent::new( + KeyCode::Esc, + KeyModifiers::NONE, + ))); + assert!(side_return_shortcut_matches(KeyEvent::new_with_kind( + KeyCode::Esc, + KeyModifiers::NONE, + KeyEventKind::Repeat, + ))); + assert!(side_return_shortcut_matches(KeyEvent::new( + KeyCode::Char('c'), + KeyModifiers::CONTROL, + ))); + assert!(side_return_shortcut_matches(KeyEvent::new( + KeyCode::Char('C'), + KeyModifiers::CONTROL, + ))); + assert!(!side_return_shortcut_matches(KeyEvent::new( + KeyCode::Char('d'), + KeyModifiers::CONTROL, + ))); + assert!(!side_return_shortcut_matches(KeyEvent::new_with_kind( + KeyCode::Esc, + KeyModifiers::NONE, + KeyEventKind::Release, + ))); + } + + #[tokio::test] + async fn side_start_block_message_tracks_open_side_conversation() { + let mut app = make_test_app().await; + assert_eq!( + app.side_start_block_message(), + Some("'/side' is unavailable until the main thread is ready.") + ); + + app.primary_thread_id = Some(ThreadId::new()); + assert_eq!(app.side_start_block_message(), None); + + let parent_thread_id = ThreadId::new(); + let side_thread_id = ThreadId::new(); + app.side_threads + .insert(side_thread_id, SideThreadState::new(parent_thread_id)); + + assert_eq!( + app.side_start_block_message(), + Some( + "A side conversation is already open. Press Esc to return before starting another." + ) + ); + + app.side_threads.remove(&side_thread_id); + assert_eq!(app.side_start_block_message(), None); + } + + #[tokio::test] + async fn side_parent_status_tracks_parent_turn_lifecycle() -> color_eyre::eyre::Result<()> { + let mut app = make_test_app().await; + let parent_thread_id = ThreadId::new(); + let side_thread_id = ThreadId::new(); + app.primary_thread_id = Some(parent_thread_id); + app.active_thread_id = Some(side_thread_id); + app.side_threads + .insert(side_thread_id, SideThreadState::new(parent_thread_id)); + + app.enqueue_thread_notification( + parent_thread_id, + turn_completed_notification(parent_thread_id, "turn-1", TurnStatus::Completed), + ) + .await?; + assert_eq!( + app.side_threads + .get(&side_thread_id) + .and_then(|state| state.parent_status), + Some(SideParentStatus::Finished) + ); + + app.enqueue_thread_notification( + parent_thread_id, + turn_started_notification(parent_thread_id, "turn-2"), + ) + .await?; + assert_eq!( + app.side_threads + .get(&side_thread_id) + .and_then(|state| state.parent_status), + None + ); + + app.enqueue_thread_notification( + parent_thread_id, + turn_completed_notification(parent_thread_id, "turn-2", TurnStatus::Failed), + ) + .await?; + assert_eq!( + app.side_threads + .get(&side_thread_id) + .and_then(|state| state.parent_status), + Some(SideParentStatus::Failed) + ); + + Ok(()) + } + + #[tokio::test] + async fn side_parent_status_prioritizes_input_over_approval() -> color_eyre::eyre::Result<()> { + let mut app = make_test_app().await; + let parent_thread_id = ThreadId::new(); + let side_thread_id = ThreadId::new(); + app.primary_thread_id = Some(parent_thread_id); + app.active_thread_id = Some(side_thread_id); + app.side_threads + .insert(side_thread_id, SideThreadState::new(parent_thread_id)); + + app.enqueue_thread_request( + parent_thread_id, + exec_approval_request( + parent_thread_id, + "turn-approval", + "call-approval", + /*approval_id*/ None, + ), + ) + .await?; + assert_eq!( + app.side_threads + .get(&side_thread_id) + .and_then(|state| state.parent_status), + Some(SideParentStatus::NeedsApproval) + ); + + app.enqueue_thread_request( + parent_thread_id, + request_user_input_request(parent_thread_id, "turn-input", "call-input"), + ) + .await?; + assert_eq!( + app.side_threads + .get(&side_thread_id) + .and_then(|state| state.parent_status), + Some(SideParentStatus::NeedsInput) + ); + + app.enqueue_thread_notification( + parent_thread_id, + ServerNotification::ServerRequestResolved( + codex_app_server_protocol::ServerRequestResolvedNotification { + thread_id: parent_thread_id.to_string(), + request_id: AppServerRequestId::Integer(2), + }, + ), + ) + .await?; + assert_eq!( + app.side_threads + .get(&side_thread_id) + .and_then(|state| state.parent_status), + Some(SideParentStatus::NeedsApproval) + ); + + app.enqueue_thread_notification( + parent_thread_id, + ServerNotification::ServerRequestResolved( + codex_app_server_protocol::ServerRequestResolvedNotification { + thread_id: parent_thread_id.to_string(), + request_id: AppServerRequestId::Integer(1), + }, + ), + ) + .await?; + assert_eq!( + app.side_threads + .get(&side_thread_id) + .and_then(|state| state.parent_status), + None + ); + + Ok(()) + } + + #[test] + fn side_start_error_message_explains_missing_first_prompt() { + let err = color_eyre::eyre::eyre!( + "thread/fork failed during TUI bootstrap: thread/fork failed: no rollout found for thread id 019da1a1-bed9-7a43-88a2-b49d43915021" + ); + + assert_eq!( + App::side_start_error_message(&err), + "'/side' is unavailable until the current conversation has started. Send a message first, then try /side again." + ); + } + + #[test] + fn side_start_error_message_uses_generic_start_wording() { + let err = color_eyre::eyre::eyre!("transport disconnected"); + + assert_eq!( + App::side_start_error_message(&err), + "Failed to start side conversation: transport disconnected" + ); + } + + #[tokio::test] + async fn side_thread_snapshot_hides_forked_parent_transcript() { + let parent_thread_id = ThreadId::new(); + let side_thread_id = ThreadId::new(); + let mut store = ThreadEventStore::new(/*capacity*/ 4); + let session = ThreadSessionState { + forked_from_id: Some(parent_thread_id), + ..test_thread_session(side_thread_id, test_path_buf("/tmp/side")) + }; + let parent_turn = test_turn( + "parent-turn", + TurnStatus::Completed, + vec![ThreadItem::UserMessage { + id: "parent-user".to_string(), + content: vec![AppServerUserInput::Text { + text: "parent prompt should stay hidden".to_string(), + text_elements: Vec::new(), + }], + }], + ); + + App::install_side_thread_snapshot(&mut store, session, vec![parent_turn]); + + let stored_session = store.session.as_ref().expect("side session"); + assert_eq!(stored_session.thread_id, side_thread_id); + assert_eq!(stored_session.forked_from_id, None); + assert_eq!(store.turns, Vec::::new()); + assert_eq!(store.active_turn_id(), None); + } + + #[tokio::test] + async fn side_thread_snapshot_does_not_refresh_from_fork_history() { + let mut app = make_test_app().await; + let parent_thread_id = ThreadId::new(); + let side_thread_id = ThreadId::new(); + app.side_threads + .insert(side_thread_id, SideThreadState::new(parent_thread_id)); + + let snapshot = ThreadEventSnapshot { + session: Some(ThreadSessionState { + rollout_path: None, + ..test_thread_session(side_thread_id, test_path_buf("/tmp/side")) + }), + turns: Vec::new(), + events: Vec::new(), + input_state: None, + }; + + assert!(!app.should_refresh_snapshot_session( + side_thread_id, + /*is_replay_only*/ false, + &snapshot + )); + } + + #[tokio::test] + async fn side_thread_snapshot_skips_session_header_preamble() { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + while app_event_rx.try_recv().is_ok() {} + + let parent_thread_id = ThreadId::new(); + let side_thread_id = ThreadId::new(); + app.primary_thread_id = Some(parent_thread_id); + app.side_threads + .insert(side_thread_id, SideThreadState::new(parent_thread_id)); + + let snapshot = ThreadEventSnapshot { + session: Some(ThreadSessionState { + forked_from_id: Some(parent_thread_id), + ..test_thread_session(side_thread_id, test_path_buf("/tmp/side")) + }), + turns: Vec::new(), + events: Vec::new(), + input_state: None, + }; + + app.replay_thread_snapshot(snapshot, /*resume_restored_queue*/ false); + + let mut rendered_cells = Vec::new(); + while let Ok(event) = app_event_rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event { + rendered_cells.push(lines_to_single_string(&cell.display_lines(/*width*/ 120))); + } + } + assert_eq!(app.chat_widget.thread_id(), Some(side_thread_id)); + assert_eq!(rendered_cells, Vec::::new()); + assert_eq!( + app.chat_widget.active_cell_transcript_lines(/*width*/ 120), + None + ); + } + + #[tokio::test] + async fn side_thread_ignores_global_mcp_startup_notifications() { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + while app_event_rx.try_recv().is_ok() {} + let app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let parent_thread_id = ThreadId::new(); + let side_thread_id = ThreadId::new(); + app.primary_thread_id = Some(parent_thread_id); + app.active_thread_id = Some(side_thread_id); + app.side_threads + .insert(side_thread_id, SideThreadState::new(parent_thread_id)); + app.sync_side_thread_ui(); + + app.handle_app_server_event( + &app_server, + codex_app_server_client::AppServerEvent::ServerNotification( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + name: "sentry".to_string(), + status: McpServerStartupState::Failed, + error: Some("sentry is not logged in".to_string()), + }), + ), + ) + .await; + + assert!(app_event_rx.try_recv().is_err()); + } + + #[tokio::test] + async fn side_restore_user_message_puts_inline_question_back_in_composer() { + let mut app = make_test_app().await; + let user_message = crate::chatwidget::UserMessage::from("side question"); + + app.restore_side_user_message(Some(user_message)); + + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "side question" + ); + } + + #[tokio::test] + async fn side_discard_selection_keeps_current_side_thread() { + let mut app = make_test_app().await; + let parent_thread_id = ThreadId::new(); + let side_thread_id = ThreadId::new(); + app.active_thread_id = Some(side_thread_id); + app.side_threads + .insert(side_thread_id, SideThreadState::new(parent_thread_id)); + + assert_eq!( + app.side_thread_to_discard_after_switch(side_thread_id), + None + ); + assert_eq!( + app.side_thread_to_discard_after_switch(parent_thread_id), + Some(side_thread_id) + ); + } + + #[tokio::test] + async fn discard_side_thread_removes_agent_navigation_entry() -> color_eyre::eyre::Result<()> { + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + let mut side_config = app.chat_widget.config_ref().clone(); + side_config.ephemeral = true; + let started = app_server.start_thread(&side_config).await?; + let side_thread_id = started.session.thread_id; + app.side_threads + .insert(side_thread_id, SideThreadState::new(ThreadId::new())); + app.agent_navigation.upsert( + side_thread_id, + Some("Side".to_string()), + Some("side".to_string()), + /*is_closed*/ false, + ); + + assert!( + app.discard_side_thread(&mut app_server, side_thread_id) + .await + ); + + assert_eq!(app.agent_navigation.get(&side_thread_id), None); + assert!(!app.side_threads.contains_key(&side_thread_id)); + Ok(()) + } + + #[tokio::test] + async fn discard_side_thread_keeps_local_state_when_server_close_fails() + -> color_eyre::eyre::Result<()> { + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + let parent_thread_id = ThreadId::new(); + let side_thread_id = ThreadId::new(); + app.active_thread_id = Some(side_thread_id); + app.side_threads + .insert(side_thread_id, SideThreadState::new(parent_thread_id)); + app.agent_navigation.upsert( + side_thread_id, + Some("Side".to_string()), + Some("side".to_string()), + /*is_closed*/ false, + ); + + assert!( + !app.discard_side_thread(&mut app_server, side_thread_id) + .await + ); + + assert_eq!(app.active_thread_id, Some(side_thread_id)); + assert_eq!( + app.side_threads + .get(&side_thread_id) + .map(|state| state.parent_thread_id), + Some(parent_thread_id) + ); + assert!(app.agent_navigation.get(&side_thread_id).is_some()); + Ok(()) + } + + #[tokio::test] + async fn discard_closed_side_thread_removes_local_state_without_server_rpc() { + let mut app = make_test_app().await; + let parent_thread_id = ThreadId::new(); + let side_thread_id = ThreadId::new(); + app.active_thread_id = Some(side_thread_id); + app.side_threads + .insert(side_thread_id, SideThreadState::new(parent_thread_id)); + app.thread_event_channels + .insert(side_thread_id, ThreadEventChannel::new(/*capacity*/ 4)); + app.agent_navigation.upsert( + side_thread_id, + Some("Side".to_string()), + Some("side".to_string()), + /*is_closed*/ false, + ); + + app.discard_closed_side_thread(side_thread_id).await; + + assert_eq!(app.active_thread_id, None); + assert!(!app.side_threads.contains_key(&side_thread_id)); + assert!(!app.thread_event_channels.contains_key(&side_thread_id)); + assert_eq!(app.agent_navigation.get(&side_thread_id), None); + } +} diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs new file mode 100644 index 0000000000..a70afa8b18 --- /dev/null +++ b/codex-rs/tui/src/app/test_support.rs @@ -0,0 +1,405 @@ +//! Shared test fixtures and builders for `crate::app` tests. + +use super::*; +use crate::app_backtrack::BacktrackState; +use crate::chatwidget::tests::make_chatwidget_manual_with_sender; +use crate::file_search::FileSearchManager; +use crate::legacy_core::config::ConfigOverrides; +use crate::test_support::PathBufExt; +use crate::test_support::test_path_buf; +use codex_app_server_protocol::AgentMessageDeltaNotification; +use codex_app_server_protocol::CommandExecutionRequestApprovalParams; +use codex_app_server_protocol::HookCompletedNotification; +use codex_app_server_protocol::HookEventName as AppServerHookEventName; +use codex_app_server_protocol::HookExecutionMode as AppServerHookExecutionMode; +use codex_app_server_protocol::HookHandlerType as AppServerHookHandlerType; +use codex_app_server_protocol::HookOutputEntry as AppServerHookOutputEntry; +use codex_app_server_protocol::HookOutputEntryKind as AppServerHookOutputEntryKind; +use codex_app_server_protocol::HookRunStatus as AppServerHookRunStatus; +use codex_app_server_protocol::HookRunSummary as AppServerHookRunSummary; +use codex_app_server_protocol::HookScope as AppServerHookScope; +use codex_app_server_protocol::HookStartedNotification; +use codex_app_server_protocol::RequestId as AppServerRequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ThreadClosedNotification; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::TokenUsageBreakdown; +use codex_app_server_protocol::ToolRequestUserInputParams; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus; +use codex_otel::SessionTelemetry; +use codex_protocol::ThreadId; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; +use codex_utils_absolute_path::AbsolutePathBuf; +use ratatui::prelude::Line; +use std::collections::HashMap; +use std::collections::VecDeque; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; + +pub(in crate::app) async fn make_test_app() -> App { + let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await; + let config = chat_widget.config_ref().clone(); + let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone()); + let model = crate::legacy_core::test_support::get_model_offline(config.model.as_deref()); + let session_telemetry = test_session_telemetry(&config, model.as_str()); + + App { + model_catalog: chat_widget.model_catalog(), + session_telemetry, + app_event_tx, + chat_widget, + config, + active_profile: None, + cli_kv_overrides: Vec::new(), + harness_overrides: ConfigOverrides::default(), + runtime_approval_policy_override: None, + runtime_sandbox_policy_override: None, + file_search, + transcript_cells: Vec::new(), + overlay: None, + deferred_history_lines: Vec::new(), + has_emitted_history_lines: false, + enhanced_keys_supported: false, + commit_anim_running: Arc::new(AtomicBool::new(false)), + status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), + terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), + backtrack: BacktrackState::default(), + backtrack_render_pending: false, + feedback: codex_feedback::CodexFeedback::new(), + feedback_audience: FeedbackAudience::External, + environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), + remote_app_server_url: None, + remote_app_server_auth_token: None, + pending_update_action: None, + pending_shutdown_exit_thread_id: None, + windows_sandbox: WindowsSandboxState::default(), + thread_event_channels: HashMap::new(), + thread_event_listener_tasks: HashMap::new(), + agent_navigation: AgentNavigationState::default(), + side_threads: HashMap::new(), + active_thread_id: None, + active_thread_rx: None, + primary_thread_id: None, + last_subagent_backfill_attempt: None, + primary_session_configured: None, + pending_primary_events: VecDeque::new(), + pending_app_server_requests: PendingAppServerRequests::default(), + pending_plugin_enabled_writes: HashMap::new(), + } +} + +pub(in crate::app) async fn make_test_app_with_channels() -> ( + App, + tokio::sync::mpsc::UnboundedReceiver, + tokio::sync::mpsc::UnboundedReceiver, +) { + let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender().await; + let config = chat_widget.config_ref().clone(); + let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone()); + let model = crate::legacy_core::test_support::get_model_offline(config.model.as_deref()); + let session_telemetry = test_session_telemetry(&config, model.as_str()); + + ( + App { + model_catalog: chat_widget.model_catalog(), + session_telemetry, + app_event_tx, + chat_widget, + config, + active_profile: None, + cli_kv_overrides: Vec::new(), + harness_overrides: ConfigOverrides::default(), + runtime_approval_policy_override: None, + runtime_sandbox_policy_override: None, + file_search, + transcript_cells: Vec::new(), + overlay: None, + deferred_history_lines: Vec::new(), + has_emitted_history_lines: false, + enhanced_keys_supported: false, + commit_anim_running: Arc::new(AtomicBool::new(false)), + status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), + terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), + backtrack: BacktrackState::default(), + backtrack_render_pending: false, + feedback: codex_feedback::CodexFeedback::new(), + feedback_audience: FeedbackAudience::External, + environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), + remote_app_server_url: None, + remote_app_server_auth_token: None, + pending_update_action: None, + pending_shutdown_exit_thread_id: None, + windows_sandbox: WindowsSandboxState::default(), + thread_event_channels: HashMap::new(), + thread_event_listener_tasks: HashMap::new(), + agent_navigation: AgentNavigationState::default(), + side_threads: HashMap::new(), + active_thread_id: None, + active_thread_rx: None, + primary_thread_id: None, + last_subagent_backfill_attempt: None, + primary_session_configured: None, + pending_primary_events: VecDeque::new(), + pending_app_server_requests: PendingAppServerRequests::default(), + pending_plugin_enabled_writes: HashMap::new(), + }, + rx, + op_rx, + ) +} + +pub(in crate::app) fn test_absolute_path(path: &str) -> AbsolutePathBuf { + AbsolutePathBuf::try_from(PathBuf::from(path)).expect("absolute test path") +} + +pub(in crate::app) fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState { + ThreadSessionState { + thread_id, + forked_from_id: None, + fork_parent_title: None, + thread_name: None, + model: "gpt-test".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: cwd.abs(), + instruction_source_paths: Vec::new(), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + network_proxy: None, + rollout_path: Some(PathBuf::new()), + } +} + +pub(in crate::app) fn test_turn(turn_id: &str, status: TurnStatus, items: Vec) -> Turn { + Turn { + id: turn_id.to_string(), + items, + status, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + } +} + +pub(in crate::app) fn turn_started_notification( + thread_id: ThreadId, + turn_id: &str, +) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + started_at: Some(0), + ..test_turn(turn_id, TurnStatus::InProgress, Vec::new()) + }, + }) +} + +pub(in crate::app) fn turn_completed_notification( + thread_id: ThreadId, + turn_id: &str, + status: TurnStatus, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + completed_at: Some(0), + duration_ms: Some(1), + ..test_turn(turn_id, status, Vec::new()) + }, + }) +} + +pub(in crate::app) fn thread_closed_notification(thread_id: ThreadId) -> ServerNotification { + ServerNotification::ThreadClosed(ThreadClosedNotification { + thread_id: thread_id.to_string(), + }) +} + +pub(in crate::app) fn token_usage_notification( + thread_id: ThreadId, + turn_id: &str, + model_context_window: Option, +) -> ServerNotification { + ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: ThreadTokenUsage { + total: TokenUsageBreakdown { + total_tokens: 10, + input_tokens: 4, + cached_input_tokens: 1, + output_tokens: 5, + reasoning_output_tokens: 0, + }, + last: TokenUsageBreakdown { + total_tokens: 10, + input_tokens: 4, + cached_input_tokens: 1, + output_tokens: 5, + reasoning_output_tokens: 0, + }, + model_context_window, + }, + }) +} + +pub(in crate::app) fn hook_started_notification( + thread_id: ThreadId, + turn_id: &str, +) -> ServerNotification { + ServerNotification::HookStarted(HookStartedNotification { + thread_id: thread_id.to_string(), + turn_id: Some(turn_id.to_string()), + run: AppServerHookRunSummary { + id: "user-prompt-submit:0:/tmp/hooks.json".to_string(), + event_name: AppServerHookEventName::UserPromptSubmit, + handler_type: AppServerHookHandlerType::Command, + execution_mode: AppServerHookExecutionMode::Sync, + scope: AppServerHookScope::Turn, + source_path: test_path_buf("/tmp/hooks.json").abs(), + source: codex_app_server_protocol::HookSource::User, + display_order: 0, + status: AppServerHookRunStatus::Running, + status_message: Some("checking go-workflow input policy".to_string()), + started_at: 1, + completed_at: None, + duration_ms: None, + entries: Vec::new(), + }, + }) +} + +pub(in crate::app) fn hook_completed_notification( + thread_id: ThreadId, + turn_id: &str, +) -> ServerNotification { + ServerNotification::HookCompleted(HookCompletedNotification { + thread_id: thread_id.to_string(), + turn_id: Some(turn_id.to_string()), + run: AppServerHookRunSummary { + id: "user-prompt-submit:0:/tmp/hooks.json".to_string(), + event_name: AppServerHookEventName::UserPromptSubmit, + handler_type: AppServerHookHandlerType::Command, + execution_mode: AppServerHookExecutionMode::Sync, + scope: AppServerHookScope::Turn, + source_path: test_path_buf("/tmp/hooks.json").abs(), + source: codex_app_server_protocol::HookSource::User, + display_order: 0, + status: AppServerHookRunStatus::Stopped, + status_message: Some("checking go-workflow input policy".to_string()), + started_at: 1, + completed_at: Some(11), + duration_ms: Some(10), + entries: vec![ + AppServerHookOutputEntry { + kind: AppServerHookOutputEntryKind::Warning, + text: "go-workflow must start from PlanMode".to_string(), + }, + AppServerHookOutputEntry { + kind: AppServerHookOutputEntryKind::Stop, + text: "prompt blocked".to_string(), + }, + ], + }, + }) +} + +pub(in crate::app) fn agent_message_delta_notification( + thread_id: ThreadId, + turn_id: &str, + item_id: &str, + delta: &str, +) -> ServerNotification { + ServerNotification::AgentMessageDelta(AgentMessageDeltaNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + item_id: item_id.to_string(), + delta: delta.to_string(), + }) +} + +pub(in crate::app) fn exec_approval_request( + thread_id: ThreadId, + turn_id: &str, + item_id: &str, + approval_id: Option<&str>, +) -> ServerRequest { + ServerRequest::CommandExecutionRequestApproval { + request_id: AppServerRequestId::Integer(1), + params: CommandExecutionRequestApprovalParams { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + item_id: item_id.to_string(), + approval_id: approval_id.map(str::to_string), + reason: Some("needs approval".to_string()), + network_approval_context: None, + command: Some("echo hello".to_string()), + cwd: Some(test_path_buf("/tmp/project").abs()), + command_actions: None, + additional_permissions: None, + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + available_decisions: None, + }, + } +} + +pub(in crate::app) fn request_user_input_request( + thread_id: ThreadId, + turn_id: &str, + item_id: &str, +) -> ServerRequest { + ServerRequest::ToolRequestUserInput { + request_id: AppServerRequestId::Integer(2), + params: ToolRequestUserInputParams { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + item_id: item_id.to_string(), + questions: Vec::new(), + }, + } +} + +pub(in crate::app) fn lines_to_single_string(lines: &[Line<'_>]) -> String { + lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n") +} + +fn test_session_telemetry(config: &Config, model: &str) -> SessionTelemetry { + let model_info = crate::legacy_core::test_support::construct_model_info_offline(model, config); + SessionTelemetry::new( + ThreadId::new(), + model, + model_info.slug.as_str(), + /*account_id*/ None, + /*account_email*/ None, + /*auth_mode*/ None, + "test_originator".to_string(), + /*log_user_prompts*/ false, + "test".to_string(), + SessionSource::Cli, + ) +} diff --git a/codex-rs/tui/src/multi_agents.rs b/codex-rs/tui/src/multi_agents.rs index 293c80fcf0..9d90a5a19d 100644 --- a/codex-rs/tui/src/multi_agents.rs +++ b/codex-rs/tui/src/multi_agents.rs @@ -592,6 +592,60 @@ mod tests { use ratatui::style::Color; use ratatui::style::Modifier; + #[test] + fn agent_picker_item_name_snapshot() { + let thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread id"); + let snapshot = [ + format!( + "{} | {}", + format_agent_picker_item_name( + Some("Robie"), + Some("explorer"), + /*is_primary*/ true + ), + thread_id + ), + format!( + "{} | {}", + format_agent_picker_item_name( + Some("Robie"), + Some("explorer"), + /*is_primary*/ false + ), + thread_id + ), + format!( + "{} | {}", + format_agent_picker_item_name( + Some("Robie"), + /*agent_role*/ None, + /*is_primary*/ false + ), + thread_id + ), + format!( + "{} | {}", + format_agent_picker_item_name( + /*agent_nickname*/ None, + Some("explorer"), + /*is_primary*/ false + ), + thread_id + ), + format!( + "{} | {}", + format_agent_picker_item_name( + /*agent_nickname*/ None, /*agent_role*/ None, + /*is_primary*/ false + ), + thread_id + ), + ] + .join("\n"); + assert_snapshot!("agent_picker_item_name", snapshot); + } + #[test] fn collab_events_snapshot() { let sender_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000001") diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__agent_picker_item_name.snap b/codex-rs/tui/src/snapshots/codex_tui__multi_agents__tests__agent_picker_item_name.snap similarity index 89% rename from codex-rs/tui/src/snapshots/codex_tui__app__tests__agent_picker_item_name.snap rename to codex-rs/tui/src/snapshots/codex_tui__multi_agents__tests__agent_picker_item_name.snap index 0337f424a8..53a3763ba7 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__agent_picker_item_name.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__multi_agents__tests__agent_picker_item_name.snap @@ -1,5 +1,5 @@ --- -source: tui/src/app.rs +source: tui/src/multi_agents.rs expression: snapshot --- Main [default] | 00000000-0000-0000-0000-000000000123