//! App-level orchestration tests for the TUI. #[path = "tests/terminal_size_telemetry_tests.rs"] mod terminal_size_telemetry_tests; #[path = "tests/copy_mode_tests.rs"] mod copy_mode_tests; #[path = "tests/mcp_login_tests.rs"] mod mcp_login_tests; #[path = "tests/math_interruption_tests.rs"] mod math_interruption_tests; #[path = "tests/security_setup_tests.rs"] mod security_setup_tests; #[path = "tests/advanced_reasoning_tests.rs"] mod advanced_reasoning_tests; #[path = "tests/agents_navigation_tests.rs"] mod agents_navigation_tests; #[path = "tests/approvals_reviewer_error_tests.rs"] mod approvals_reviewer_error_tests; #[path = "tests/backend_banner_fallback_tests.rs"] mod backend_banner_fallback_tests; #[path = "tests/backend_banner_recovery_tests.rs"] mod backend_banner_recovery_tests; #[path = "tests/backend_banner_startup_tests.rs"] mod backend_banner_startup_tests; #[path = "tests/background_exit_tests.rs"] mod background_exit_tests; #[path = "tests/background_task_defaults_tests.rs"] mod background_task_defaults_tests; #[path = "tests/browsing_pagination_tests.rs"] mod browsing_pagination_tests; #[path = "tests/buffered_replay.rs"] mod buffered_replay; #[path = "tests/connector_policy.rs"] mod connector_policy; #[path = "tests/disconnect_tests.rs"] mod disconnect; #[path = "tests/external_writer_fork_tests.rs"] mod external_writer_fork_tests; #[path = "tests/fork_workspace_roots_tests.rs"] mod fork_workspace_roots_tests; #[path = "tests/fresh_sparkle_tests.rs"] mod fresh_sparkle_tests; #[path = "tests/home_cleanup_tests.rs"] mod home_cleanup_tests; #[path = "tests/key_chords.rs"] mod key_chords; #[path = "tests/local_command_scroll_tests.rs"] mod local_command_scroll_tests; #[path = "tests/luna_reserve_recovery_tests.rs"] mod luna_reserve_recovery_tests; #[path = "tests/marketplace_upgrade_tests.rs"] mod marketplace_upgrade_tests; #[path = "tests/mcp_startup.rs"] mod mcp_startup; #[path = "tests/misalignment_policy_tests.rs"] mod misalignment_policy; mod model_catalog; #[path = "tests/model_defaults_tests.rs"] mod model_defaults; #[path = "tests/pagination_completion_tests.rs"] mod pagination_completion_tests; #[path = "tests/patch_approval_tests.rs"] mod patch_approval_tests; #[path = "tests/permission_selection_tests.rs"] mod permission_selection_tests; #[path = "tests/projectless_tests.rs"] mod projectless_tests; #[path = "tests/quick_dials_tests.rs"] mod quick_dials_tests; #[path = "tests/unavailable_commands_tests.rs"] mod unavailable_commands; #[path = "tests/backend_sessions_tests.rs"] mod backend_sessions; #[path = "tests/history_hydration_tests.rs"] mod history_hydration_tests; #[path = "tests/permission_shortcuts_tests.rs"] mod permission_shortcuts_tests; mod plugin_catalog; mod rate_limits; #[path = "tests/realtime_handoff_e2e.rs"] mod realtime_handoff_e2e; #[path = "tests/realtime_requests.rs"] mod realtime_requests; #[path = "tests/realtime_start.rs"] mod realtime_start; #[path = "tests/reasoning_resume_tests.rs"] mod reasoning_resume_tests; #[path = "tests/recap_generation_tests.rs"] mod recap_generation; #[path = "tests/resume_shutdown_tests.rs"] mod resume_shutdown_tests; mod safety_buffering; #[path = "tests/session_lifecycle_requests.rs"] mod session_lifecycle_requests; mod startup; #[path = "tests/startup_frame_tests.rs"] mod startup_frame_tests; #[path = "tests/startup_warnings_tests.rs"] mod startup_warnings_tests; #[path = "tests/stream_animation_tests.rs"] mod stream_animation_tests; #[path = "tests/streaming_interleave_e2e.rs"] mod streaming_interleave_e2e; #[path = "tests/thread_usage.rs"] mod thread_usage; #[path = "tests/transcript_composer.rs"] mod transcript_composer; #[path = "tests/transcript_selection.rs"] mod transcript_selection; #[path = "tests/turn_submission.rs"] mod turn_submission; #[path = "tests/user_verification_routes_tests.rs"] mod user_verification_routes; #[path = "tests/worktree_background_terminals_tests.rs"] mod worktree_background_terminals_tests; use super::agent_navigation::AgentPickerThreadVisibility; use super::*; use crate::app_backtrack::BacktrackSelection; use crate::app_backtrack::BacktrackState; use crate::app_backtrack::nth_user_position; use crate::app_backtrack::user_count; use crate::app_event::HistoryBatchEntryResponse; use crate::test_support::boxed_future; use codex_app_server_protocol::ThreadItem; async fn drain_managed_worktree_start(app: &mut App, server: &mut AppServerSession) { if let Some((mode, name)) = app.pending_start_managed_worktree.take() { app.start_managed_worktree(server, mode, name).await; } } use codex_utils_absolute_path::test_support::PathExt; use crate::chatwidget::ChatWidgetInit; use crate::chatwidget::create_initial_user_message; use crate::chatwidget::tests::helpers::normalize_snapshot_paths; use crate::chatwidget::tests::helpers::render_bottom_popup; use crate::chatwidget::tests::helpers::set_active_cell; 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::goal_files; use crate::history_cell::AgentMarkdownCell; use crate::history_cell::AgentMessageCell; use crate::history_cell::HistoryCell; use crate::history_cell::PlainHistoryCell; use crate::history_cell::UserHistoryCell; use crate::history_cell::new_session_info; use crate::multi_agents::AgentPickerThreadEntry; use crate::multi_agents::SubAgentActivityDisplay; use assert_matches::assert_matches; use crate::app_command::AppCommand as Op; use crate::app_event::ConsolidationScrollbackReflow; use crate::diff_model::FileChange; use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; use crate::legacy_core::config::PermissionProfileSnapshot; use codex_app_server_client::AppServerPath; 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::AskForApproval; use codex_app_server_protocol::CommandExecutionRequestApprovalParams; use codex_app_server_protocol::FileChangeRequestApprovalParams; use codex_app_server_protocol::FileUpdateChange; use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; 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; use codex_app_server_protocol::NetworkPolicyRuleAction as AppServerNetworkPolicyRuleAction; use codex_app_server_protocol::NonSteerableTurnKind as AppServerNonSteerableTurnKind; use codex_app_server_protocol::PatchChangeKind; use codex_app_server_protocol::PermissionsRequestApprovalParams; 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::Thread; use codex_app_server_protocol::ThreadArchivedNotification; use codex_app_server_protocol::ThreadAttachmentOperation; use codex_app_server_protocol::ThreadAttachmentUpdatedNotification; use codex_app_server_protocol::ThreadClosedNotification; use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadSettings; use codex_app_server_protocol::ThreadSettingsUpdatedNotification; use codex_app_server_protocol::ThreadStartedNotification; use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; use codex_app_server_protocol::ThreadUnarchivedNotification; 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; use codex_app_server_protocol::UserInput as AppServerUserInput; use codex_app_server_protocol::WarningNotification; use codex_history::RolloutItem; use codex_models_manager::test_support::construct_model_info_offline_for_tests; use codex_models_manager::test_support::get_model_offline_for_tests; use codex_otel::SessionTelemetry; use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::Settings; use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::NetworkPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::MODEL_SPECIALTY_CYBER; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::MAX_THREAD_GOAL_OBJECTIVE_CHARS; use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::SessionMeta; use codex_protocol::protocol::SessionSource as RolloutSessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::user_input::TextElement; use codex_utils_absolute_path::AbsolutePathBuf; use crossterm::event::KeyModifiers; use insta::assert_snapshot; use pretty_assertions::assert_eq; use ratatui::buffer::Buffer; use ratatui::prelude::Line; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; use tempfile::tempdir; use tokio::time; macro_rules! assert_app_snapshot { ($name:expr, $value:expr $(,)?) => { insta::with_settings!({snapshot_path => "../snapshots"}, { assert_snapshot!($name, $value); }); }; } fn test_absolute_path(path: &str) -> AbsolutePathBuf { AbsolutePathBuf::try_from(PathBuf::from(path)).expect("absolute test path") } #[tokio::test] async fn pasted_text_normalizes_mixed_line_endings_at_app_boundary() -> Result<()> { let mut app = make_test_app().await; let mut app_server = start_config_write_test_app_server(&app).await?; let mut tui = crate::tui::test_support::make_test_tui()?; app.handle_tui_event( &mut tui, &mut app_server, TuiEvent::Paste("line1\r\nline2\rline3\nline4".to_string()), ) .await?; assert_snapshot!(app.chat_widget.composer_text_with_pending(), @r" line1 line2 line3 line4 "); Ok(()) } #[tokio::test] async fn program_status_acknowledges_deliberate_mouse_events() -> Result<()> { use crate::terminal_program_status::ProgramStatus; use crossterm::event::MouseButton; use crossterm::event::MouseEvent; use crossterm::event::MouseEventKind; let mut app = make_test_app().await; let mut app_server = start_config_write_test_app_server(&app).await?; let mut tui = crate::tui::test_support::make_test_tui()?; let thread_id = ThreadId::new(); for (turn_status, expected) in [ (TurnStatus::Completed, ProgramStatus::Done), (TurnStatus::Failed, ProgramStatus::Error), ] { for kind in [ MouseEventKind::Down(MouseButton::Left), MouseEventKind::Drag(MouseButton::Left), MouseEventKind::Up(MouseButton::Left), MouseEventKind::ScrollUp, MouseEventKind::ScrollDown, ] { app.chat_widget.handle_server_notification( turn_started_notification(thread_id, "result"), /*replay_kind*/ None, ); app.chat_widget.handle_server_notification( turn_completed_notification(thread_id, "result", turn_status.clone()), /*replay_kind*/ None, ); let before = app.chat_widget.desired_program_status(); let mut mouse = MouseEvent { kind: MouseEventKind::Moved, column: 1, row: 1, modifiers: KeyModifiers::NONE, }; app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Mouse(mouse)) .await?; let after_hover = app.chat_widget.desired_program_status(); mouse.kind = kind; app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Mouse(mouse)) .await?; assert_eq!( [ before, after_hover, app.chat_widget.desired_program_status() ], [expected, expected, ProgramStatus::Idle], "after {turn_status:?}, {kind:?}", ); } } app_server.shutdown().await?; Ok(()) } #[tokio::test] async fn duplicate_named_resume_renders_error_without_replacing_current_session() -> Result<()> { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; let label = format!("duplicate-{}", ThreadId::new()); let runtime = codex_state::StateRuntime::init( app.config.sqlite.clone(), app.config.model_provider_id.clone(), ) .await .map_err(std::io::Error::other)?; runtime .mark_backfill_complete(/*last_watermark*/ None) .await .map_err(std::io::Error::other)?; let mut thread_ids = Vec::new(); for _ in 0..2 { let filename_ts = "2025-02-01T10-00-00"; let timestamp = "2025-02-01T10:00:00Z"; let thread_id = app_test_support::create_fake_rollout( app.config.codex_home.as_path(), filename_ts, timestamp, &label, Some(&app.config.model_provider_id), /*git_info*/ None, ) .expect("materialized rollout should be created"); let path = app_test_support::rollout_path( app.config.codex_home.as_path(), filename_ts, &thread_id, ); let thread_id = ThreadId::from_string(&thread_id)?; thread_ids.push(thread_id); let created_at = chrono::DateTime::parse_from_rfc3339(timestamp)?.with_timezone(&chrono::Utc); let mut metadata = codex_state::ThreadMetadataBuilder::new( thread_id, path.to_path_buf(), created_at, serde_json::from_value(serde_json::json!("cli"))?, ) .build(&app.config.model_provider_id); metadata.title = label.clone(); metadata.first_user_message = Some(label.clone()); metadata.preview = Some(label.clone()); runtime .upsert_thread(&metadata) .await .map_err(std::io::Error::other)?; } let current = ThreadId::new(); app.active_thread_id = Some(current); app.transcript_cells = vec![plain_line_cell("Existing conversation")]; let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?; let mut tui = crate::tui::test_support::make_test_tui()?; app.handle_event( &mut tui, &mut app_server, AppEvent::ResumeSessionByIdOrName(label.clone()), ) .await?; while let Ok(event) = app_event_rx.try_recv() { if let AppEvent::InsertHistoryCell(cell) = event { app.transcript_cells.push(Arc::from(cell)); } } let rendered = app .render_transcript_lines_for_reflow(/*width*/ 80) .lines .iter() .map(rendered_line_text) .collect::>() .join("\n"); assert!( thread_ids .iter() .all(|id| rendered.contains(&id.to_string())) ); let rendered = thread_ids .iter() .fold(rendered.replace(&label, "duplicate-label"), |text, id| { text.replace(&id.to_string(), "") }); assert_app_snapshot!("duplicate_named_resume_error", rendered); assert_eq!(app.active_thread_id, Some(current)); app_server.shutdown().await?; Ok(()) } #[tokio::test] async fn chat_widget_frame_reuses_active_cell_height_across_frame_passes() { #[derive(Debug)] struct CountingHistoryCell { desired_height_calls: Arc, } impl HistoryCell for CountingHistoryCell { fn display_lines(&self, _width: u16) -> Vec> { vec![Line::from("active cell")] } fn raw_lines(&self) -> Vec> { vec![Line::from("active cell")] } fn desired_height(&self, _width: u16) -> u16 { self.desired_height_calls.fetch_add(1, Ordering::Relaxed); 1 } } let mut app = make_test_app().await; let desired_height_calls = Arc::new(AtomicUsize::new(0)); set_active_cell( &mut app.chat_widget, Box::new(CountingHistoryCell { desired_height_calls: Arc::clone(&desired_height_calls), }), ); let width = 80; app.with_chat_widget_frame(width, |desired_height, chat_widget| { let area = Rect::new(/*x*/ 0, /*y*/ 0, width, desired_height); let mut buffer = Buffer::empty(area); chat_widget.render(area, &mut buffer); assert!(chat_widget.cursor_pos(area).is_some()); let _ = chat_widget.cursor_style(area); }); assert_eq!(desired_height_calls.load(Ordering::Relaxed), 1); } async fn next_thread_settings_updated( app_server: &mut AppServerSession, thread_id: ThreadId, ) -> ThreadSettingsUpdatedNotification { for _ in 0..20 { let event = time::timeout( std::time::Duration::from_secs(/*secs*/ 2), app_server.next_event(), ) .await .expect("app-server should emit an event") .expect("app-server event stream should remain open"); if let codex_app_server_client::AppServerEvent::ServerNotification(notification) = event.event && let ServerNotification::ThreadSettingsUpdated(notification) = *notification && notification.thread_id == thread_id.to_string() { return notification; } } panic!("expected ThreadSettingsUpdated for thread {thread_id}"); } #[tokio::test] async fn handle_mcp_inventory_result_respects_origin_thread() { let mut app = make_test_app().await; app.transcript_cells .push(Arc::new(history_cell::new_mcp_inventory_loading( /*animations_enabled*/ false, ))); app.handle_mcp_inventory_result( Ok(vec![McpServerStatus { server_capabilities: None, tool_list_meta: None, tools_error: None, name: "docs".to_string(), runtime_status: None, plugin_id: None, http_origin: None, server_info: None, tools: HashMap::new(), resources: Vec::new(), resource_templates: Vec::new(), auth_status: codex_app_server_protocol::McpAuthStatus::Unsupported, }]), McpServerStatusDetail::ToolsAndAuthOnly, /*thread_id*/ None, ); assert_eq!(app.transcript_cells.len(), 0); app.active_thread_id = Some(ThreadId::new()); app.transcript_cells .push(Arc::new(history_cell::new_mcp_inventory_loading( /*animations_enabled*/ false, ))); app.handle_mcp_inventory_result( Ok(Vec::new()), McpServerStatusDetail::ToolsAndAuthOnly, Some(ThreadId::new()), ); assert_eq!(app.transcript_cells.len(), 1); } #[test] fn bypass_hook_trust_startup_warning_snapshot() { let rendered = lines_to_single_string( &history_cell::new_warning_event( "`--dangerously-bypass-hook-trust` is enabled. Enabled hooks may run without review for this invocation." .to_string(), ) .transcript_lines(/*width*/ 80), ); assert_app_snapshot!("bypass_hook_trust_startup_warning", rendered); } #[tokio::test] async fn cyber_model_auto_review_notice_snapshot() -> Result<()> { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; let mut tui = crate::tui::test_support::make_test_tui()?; app.handle_event( &mut tui, &mut app_server, AppEvent::CyberModelAutoReviewNotice, ) .await?; let cell = match app_event_rx.try_recv() { Ok(AppEvent::InsertHistoryCell(cell)) => cell, other => panic!("expected InsertHistoryCell event, got {other:?}"), }; let rendered = lines_to_single_string(&cell.transcript_lines(/*width*/ 80)); assert_app_snapshot!("cyber_model_auto_review_notice", rendered); Ok(()) } #[tokio::test] #[serial_test::serial] async fn external_editor_writable_directory_rejected_snapshot() -> Result<()> { struct RestoreVisual(Option); impl Drop for RestoreVisual { fn drop(&mut self) { match self.0.take() { Some(value) => unsafe { std::env::set_var("VISUAL", value) }, None => unsafe { std::env::remove_var("VISUAL") }, } } } let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; let codex_home = app.chat_widget.config_ref().codex_home.clone(); let fallback_home = dirs::home_dir() .expect("home directory") .join(".codex") .abs(); let workspace_codex_home = app.chat_widget.config_ref().cwd.join(".codex"); let permission_profile = PermissionProfile::workspace_write_with( &[codex_home, fallback_home, workspace_codex_home], codex_protocol::permissions::NetworkSandboxPolicy::Restricted, /*exclude_tmpdir_env_var*/ true, /*exclude_slash_tmp*/ true, ); app.chat_widget .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::legacy( permission_profile, ))?; let mut tui = crate::tui::test_support::make_test_tui()?; let restore_visual = RestoreVisual(std::env::var_os("VISUAL")); unsafe { std::env::set_var("VISUAL", "editor") }; app.launch_external_editor(&mut tui).await; drop(restore_visual); let cell = match app_event_rx.try_recv() { Ok(AppEvent::InsertHistoryCell(cell)) => cell, other => panic!("expected InsertHistoryCell event, got {other:?}"), }; let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80)); assert_app_snapshot!("external_editor_writable_directory_rejected", rendered); Ok(()) } #[tokio::test] async fn enqueue_primary_thread_session_replays_buffered_approval_after_attach() -> Result<()> { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; let (mut app_server, requests, proxy) = session_lifecycle_requests::start_recording_app_server( &app.config, /*blocked_thread_list*/ None, /*failed_thread_name*/ None, ) .await?; let thread_id = ThreadId::new(); let approval_request = exec_approval_request(thread_id, "turn-1", "call-1", /*approval_id*/ None); let buffered = super::app_server_requests::BufferedServerRequest::new( approval_request.clone(), &app_server.request_handle(), ); assert_eq!( app.pending_app_server_requests .note_buffered_request(&buffered), None ); app.agents_overview.dispatched_requests.insert( thread_id, vec![super::app_server_requests::BufferedServerRequest::new( approval_request.clone(), &app_server.request_handle(), )], ); app.enqueue_primary_thread_request(approval_request.clone()) .await?; app.handle_app_server_event( &app_server, codex_app_server_client::AppServerEvent::ServerNotification(Box::new( ServerNotification::ServerRequestResolved( codex_app_server_protocol::ServerRequestResolvedNotification { thread_id: ThreadId::new().to_string(), request_id: AppServerRequestId::Integer(1), }, ), )), ) .await; assert_eq!(app.agents_overview.dispatched_requests[&thread_id].len(), 1); app.enqueue_primary_thread_session( test_thread_session(thread_id, test_path_buf("/tmp/project")), Vec::new(), ) .await?; let rx = app .active_thread_rx .as_mut() .expect("primary thread receiver should be active"); let event = time::timeout(Duration::from_millis(50), rx.recv()) .await .expect("timed out waiting for buffered approval event") .expect("channel closed unexpectedly"); assert!(matches!( &event, ThreadBufferedEvent::Request(request) if matches!( request.as_ref(), ServerRequest::CommandExecutionRequestApproval { params, .. } if params.turn_id == "turn-1" ) )); app.handle_thread_event_now(event); let background_thread_id = ThreadId::new(); let mut background_request = approval_request; let ServerRequest::CommandExecutionRequestApproval { request_id, params } = &mut background_request else { unreachable!("expected a command approval request"); }; *request_id = AppServerRequestId::Integer(2); params.thread_id = background_thread_id.to_string(); let buffered = super::app_server_requests::BufferedServerRequest::new( background_request.clone(), &app_server.request_handle(), ); app.pending_app_server_requests .note_buffered_request(&buffered); app.enqueue_thread_request(background_thread_id, background_request.clone()) .await?; crate::chatwidget::activate_voice_for_thread(&mut app.chat_widget, thread_id); let mut tui = crate::tui::test_support::make_test_tui()?; app.replace_chat_widget_with_app_server_thread( &mut tui, AppServerStartedThread { session: test_thread_session(ThreadId::new(), app.config.cwd.to_path_buf()), turns: Vec::new(), blocks_direct_input: false, task_tools_available: false, }, session_lifecycle::ThreadAttachPresentation::SessionLineage, /*initial_user_message*/ None, ) .await?; Box::pin(app.select_agent_thread(&mut tui, &mut app_server, thread_id)).await?; assert!( !app.pending_app_server_requests .contains_server_request(&background_request) ); let buffered = super::app_server_requests::BufferedServerRequest::new( background_request.clone(), &app_server.request_handle(), ); app.pending_app_server_requests .note_buffered_request(&buffered); app.enqueue_thread_request(background_thread_id, background_request) .await?; for expected_thread_id in [thread_id, background_thread_id] { assert!(app.chat_widget.has_active_view()); app.chat_widget .handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); let (actual_thread_id, op) = std::iter::from_fn(|| app_event_rx.try_recv().ok()) .find_map(|event| match event { AppEvent::SubmitThreadOp { thread_id, op } => Some((thread_id, op)), _ => None, }) .expect("approval should submit a thread-scoped operation"); assert_eq!(actual_thread_id, expected_thread_id); app.submit_thread_op(&mut app_server, expected_thread_id, op) .await?; } assert!(!app.chat_widget.has_active_view()); app_server.shutdown().await?; proxy.await??; assert_eq!( requests .lock() .expect("request recorder lock") .iter() .filter(|request| request.method == "server/request/response") .map(|request| request.id.clone()) .collect::>(), (1..=2).map(AppServerRequestId::Integer).collect::>() ); Ok(()) } #[tokio::test] async fn resolved_buffered_approval_does_not_become_actionable_after_drain() -> Result<()> { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::new(); let approval_request = exec_approval_request(thread_id, "turn-1", "call-1", /*approval_id*/ None); app.enqueue_primary_thread_session( test_thread_session(thread_id, test_path_buf("/tmp/project")), Vec::new(), ) .await?; while app_event_rx.try_recv().is_ok() {} assert_eq!( app.pending_app_server_requests .note_server_request(&approval_request), None ); app.enqueue_thread_request(thread_id, approval_request) .await?; let resolved = app .pending_app_server_requests .resolve_notification(&thread_id.to_string(), &AppServerRequestId::Integer(1)) .expect("matching app-server request should resolve"); app.chat_widget .dismiss_app_server_request(&resolved.request); while app_event_rx.try_recv().is_ok() {} let rx = app .active_thread_rx .as_mut() .expect("primary thread receiver should be active"); let event = time::timeout(Duration::from_millis(50), rx.recv()) .await .expect("timed out waiting for buffered approval event") .expect("channel closed unexpectedly"); assert!(matches!( &event, ThreadBufferedEvent::Request(request) if matches!( request.as_ref(), ServerRequest::CommandExecutionRequestApproval { params, .. } if params.turn_id == "turn-1" ) )); app.handle_thread_event_now(event); app.chat_widget .handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); while let Ok(app_event) = app_event_rx.try_recv() { assert!( !matches!(app_event, AppEvent::SubmitThreadOp { .. }), "resolved buffered approval should not become actionable" ); } Ok(()) } #[tokio::test] async fn background_resolution_keeps_matching_foreground_user_input_visible() -> Result<()> { let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; let app_server = crate::start_embedded_app_server_for_picker(&app.config).await?; let background_thread = ThreadId::new(); let foreground_thread = ThreadId::new(); let background_request = request_user_input_request(background_thread, "turn-1", "question"); let foreground_request = request_user_input_request(foreground_thread, "turn-1", "question"); app.enqueue_primary_thread_session( test_thread_session(foreground_thread, test_path_buf("/tmp/project")), Vec::new(), ) .await?; app.pending_app_server_requests .note_server_request(&background_request); app.pending_app_server_requests .note_server_request(&foreground_request); app.handle_thread_event_now(ThreadBufferedEvent::Request(Box::new(foreground_request))); assert!(app.chat_widget.has_active_view()); app.handle_app_server_event( &app_server, codex_app_server_client::AppServerEvent::ServerNotification(Box::new( ServerNotification::ServerRequestResolved( codex_app_server_protocol::ServerRequestResolvedNotification { thread_id: background_thread.to_string(), request_id: AppServerRequestId::Integer(2), }, ), )), ) .await; assert!(app.chat_widget.has_active_view()); app_server.shutdown().await?; Ok(()) } fn set_test_initial_prompt(app: &mut App, initial_prompt: String) { let config = app.config.clone(); let model = get_model_offline_for_tests(config.model.as_deref()); app.chat_widget = ChatWidget::new_with_app_event(ChatWidgetInit { requires_openai_auth: true, local_settings: crate::local_settings::LocalSettings::from(&config), config, frame_requester: crate::tui::FrameRequester::test_dummy(), app_event_tx: app.app_event_tx.clone(), workspace_command_runner: None, initial_user_message: create_initial_user_message( Some(initial_prompt), Vec::new(), Vec::new(), ), enhanced_keys_supported: false, has_chatgpt_account: false, has_codex_backend_auth: false, model_catalog: app.model_catalog.clone(), feedback: codex_feedback::CodexFeedback::new(), is_first_run: false, status_account_display: None, initial_plan_type: None, model: Some(model), startup_tooltip_override: None, status_line_invalid_items_warned: app.status_line_invalid_items_warned.clone(), terminal_title_invalid_items_warned: app.terminal_title_invalid_items_warned.clone(), session_telemetry: app.session_telemetry.clone(), }); } #[tokio::test] async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_submit() -> Result<()> { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::new(); let initial_prompt = "follow-up after replay".to_string(); set_test_initial_prompt(&mut app, initial_prompt.clone()); app.enqueue_primary_thread_session( test_thread_session(thread_id, test_path_buf("/tmp/project")), vec![test_turn( "turn-1", TurnStatus::Completed, vec![ThreadItemPayload::UserMessage { id: "user-1".to_string(), client_id: None, content: vec![AppServerUserInput::Text { text: "earlier prompt".to_string(), text_elements: Vec::new(), }], }], )], ) .await?; let mut saw_replayed_answer = false; let mut saw_optimistic_prompt = false; let mut submitted_items = None; while let Ok(event) = app_event_rx.try_recv() { match event { AppEvent::InsertHistoryCell(cell) => { let transcript = lines_to_single_string(&cell.transcript_lines(/*width*/ 80)); saw_replayed_answer |= transcript.contains("earlier prompt"); saw_optimistic_prompt |= transcript.contains(&initial_prompt); } AppEvent::SubmitThreadOp { thread_id: op_thread_id, op: Op::UserTurn { items, .. }, } => { assert_eq!(op_thread_id, thread_id); submitted_items = Some(items); } AppEvent::CodexOp(Op::UserTurn { items, .. }) => { assert!( saw_optimistic_prompt, "expected optimistic prompt before turn submission" ); submitted_items = Some(items); } _ => {} } } assert!( saw_replayed_answer, "expected replayed history before initial prompt submit" ); assert_eq!( submitted_items, Some(vec![UserInput::Text { text: initial_prompt, text_elements: Vec::new(), }]) ); for expected in ["follow-up after replay", "earlier prompt"] { app.chat_widget.handle_key_event(KeyCode::Up.into()); assert_eq!(app.chat_widget.composer_text_with_pending(), expected); } Ok(()) } #[tokio::test] async fn reset_thread_event_state_aborts_listener_tasks() { struct NotifyOnDrop(Option>); impl Drop for NotifyOnDrop { fn drop(&mut self) { if let Some(tx) = self.0.take() { let _ = tx.send(()); } } } let mut app = make_test_app().await; let app_server = crate::start_embedded_app_server_for_picker(&app.config) .await .expect("embedded app server"); let thread_id = ThreadId::new(); let side_thread_id = ThreadId::new(); let side_request = exec_approval_request( side_thread_id, "turn-1", "call-1", /*approval_id*/ None, ); let (started_tx, started_rx) = tokio::sync::oneshot::channel(); let (dropped_tx, dropped_rx) = tokio::sync::oneshot::channel(); let handle = tokio::spawn(async move { let _notify_on_drop = NotifyOnDrop(Some(dropped_tx)); let _ = started_tx.send(()); std::future::pending::<()>().await; }); app.thread_event_listener_tasks.insert(thread_id, handle); app.thread_event_listener_tasks .insert(side_thread_id, tokio::spawn(std::future::pending())); app.thread_event_channels.insert( side_thread_id, ThreadEventChannel::new(THREAD_EVENT_CHANNEL_CAPACITY), ); app.thread_event_channels[&side_thread_id] .store .lock() .await .push_request(side_request.clone()); app.side_threads .insert(side_thread_id, SideThreadState::new(thread_id)); let buffered = super::app_server_requests::BufferedServerRequest::new( side_request.clone(), &app_server.request_handle(), ); app.pending_app_server_requests .note_buffered_request(&buffered); app.pending_server_profiles.insert( thread_id, PermissionProfileSelection { profile_id: "server-only".into(), approval_policy: None, approvals_reviewer: None, display_label: "server-only".into(), }, ); started_rx .await .expect("listener task should report it started"); app.reset_thread_event_state().await; assert_eq!( app.thread_event_listener_tasks .keys() .copied() .collect::>(), vec![side_thread_id] ); assert!(app.thread_event_channels.contains_key(&side_thread_id)); assert!(app.side_threads.contains_key(&side_thread_id)); assert!( app.pending_app_server_requests .contains_server_request(&side_request) ); assert!(app.pending_server_profiles.is_empty()); time::timeout(Duration::from_millis(50), dropped_rx) .await .expect("timed out waiting for listener task abort") .expect("listener task drop notification should succeed"); app_server.shutdown().await.expect("app server shutdown"); } #[tokio::test] async fn history_lookup_response_is_routed_to_requesting_thread() -> Result<()> { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::new(); let local_home = tempfile::tempdir()?; app.local_settings.codex_home = AbsolutePathBuf::from_absolute_path(local_home.path())?; let history_config = codex_message_history::HistoryConfig::new( app.local_settings.codex_home.clone(), &app.local_settings.history, ); codex_message_history::append_entry("local prompt", thread_id, &history_config).await?; let (log_id, _) = codex_message_history::history_metadata(&history_config).await; app.lookup_message_history_entry(thread_id, /*offset*/ 0, log_id) .await?; let app_event = tokio::time::timeout(Duration::from_secs(1), app_event_rx.recv()) .await .expect("history lookup should emit an app event") .expect("app event channel should stay open"); let AppEvent::ThreadHistoryEntryResponse { thread_id: routed_thread_id, event, } = app_event else { panic!("expected thread-routed history response"); }; assert_eq!(routed_thread_id, thread_id); assert_eq!( event, HistoryLookupResponse::Entry { offset: 0, log_id, entry: Some("local prompt".to_string()), } ); let cursor = codex_message_history::HistoryBatchCursor::new(/*end_offset*/ 1); app.lookup_message_history_batch(thread_id, cursor, log_id) .await?; let app_event = tokio::time::timeout(Duration::from_secs(1), app_event_rx.recv()) .await .expect("history batch lookup should emit an app event") .expect("app event channel should stay open"); let AppEvent::ThreadHistoryEntryResponse { thread_id: routed_thread_id, event, } = app_event else { panic!("expected thread-routed history batch response"); }; assert_eq!(routed_thread_id, thread_id); assert_eq!( event, HistoryLookupResponse::Batch { cursor, log_id, entries: vec![HistoryBatchEntryResponse { offset: 0, entry: Some("local prompt".to_string()), }], next_older_cursor: None, } ); Ok(()) } #[tokio::test] async fn enqueue_thread_event_does_not_block_when_channel_full() -> Result<()> { let mut app = make_test_app().await; let thread_id = ThreadId::new(); app.thread_event_channels .insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1)); app.set_thread_active(thread_id, /*active*/ true).await; let event = thread_closed_notification(thread_id); app.enqueue_thread_notification(thread_id, event.clone()) .await?; time::timeout( Duration::from_millis(50), app.enqueue_thread_notification(thread_id, event), ) .await .expect("enqueue_thread_notification blocked on a full channel")?; let mut rx = app .thread_event_channels .get_mut(&thread_id) .expect("missing thread channel") .receiver .take() .expect("missing receiver"); time::timeout(Duration::from_millis(50), rx.recv()) .await .expect("timed out waiting for first event") .expect("channel closed unexpectedly"); time::timeout(Duration::from_millis(50), rx.recv()) .await .expect("timed out waiting for second event") .expect("channel closed unexpectedly"); Ok(()) } #[tokio::test] async fn active_thread_drain_yields_after_frame_deadline_without_dropping_events() -> Result<()> { let mut app = make_test_app().await; app.startup_protected_input_boundary = true; let thread_id = ThreadId::new(); app.thread_event_channels .insert(thread_id, ThreadEventChannel::new(/*capacity*/ 2)); app.activate_thread_channel(thread_id).await; let first_event = token_usage_notification(thread_id, "turn-1", Some(100)); let mut second_event = token_usage_notification(thread_id, "turn-1", Some(100)); if let ServerNotification::ThreadTokenUsageUpdated(notification) = &mut second_event { notification.token_usage.total.output_tokens = 10; notification.token_usage.total.total_tokens = 15; } for event in [first_event, second_event] { app.enqueue_thread_notification(thread_id, event).await?; } let mut tui = crate::tui::test_support::make_test_tui()?; app.drain_active_thread_events_until(&mut tui, Instant::now()) .await?; assert_eq!( app.chat_widget.token_usage(), crate::token_usage::TokenUsage { input_tokens: 4, cached_input_tokens: 1, output_tokens: 5, reasoning_output_tokens: 0, total_tokens: 10, } ); assert!( app.active_thread_rx .as_ref() .is_some_and(|receiver| !receiver.is_empty()), "an expired frame deadline should preserve queued notifications" ); assert!( !app.has_queued_startup_protected_request(), "ordinary notifications left by the frame deadline must not block terminal input" ); app.drain_active_thread_events(&mut tui).await?; assert!( app.active_thread_rx .as_ref() .is_some_and(tokio::sync::mpsc::Receiver::is_empty), "the next foreground frame should deliver the preserved notification" ); assert_eq!( app.chat_widget.token_usage(), crate::token_usage::TokenUsage { input_tokens: 4, cached_input_tokens: 1, output_tokens: 10, reasoning_output_tokens: 0, total_tokens: 15, } ); Ok(()) } #[tokio::test] async fn selected_side_thread_close_is_handled_by_foreground_event_owner() -> Result<()> { let mut app = make_test_app().await; let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?; let primary = app_server.start_thread(&app.config).await?; let primary_thread_id = primary.session.thread_id; app.enqueue_primary_thread_session(primary.session, primary.turns) .await?; let side_thread_id = ThreadId::new(); let side_channel = ThreadEventChannel::new(/*capacity*/ 1); side_channel.store.lock().await.set_session( test_thread_session(side_thread_id, app.config.cwd.to_path_buf()), Vec::new(), ); side_channel .sender .try_send(ThreadBufferedEvent::Notification(Box::new( thread_closed_notification(side_thread_id), ))) .expect("closed side-thread notification should fit in its saved receiver"); app.thread_event_channels .insert(side_thread_id, side_channel); app.side_threads .insert(side_thread_id, SideThreadState::new(primary_thread_id)); let mut tui = crate::tui::test_support::make_test_tui()?; app.select_agent_thread(&mut tui, &mut app_server, side_thread_id) .await?; assert_eq!(app.active_thread_id, Some(side_thread_id)); let event = app .active_thread_rx .as_mut() .expect("selected side thread should retain its receiver") .try_recv() .expect("thread closure should remain queued for the foreground loop"); app.handle_active_thread_event(&mut tui, &mut app_server, event) .await?; assert_eq!(app.active_thread_id, Some(primary_thread_id)); assert!(!app.side_threads.contains_key(&side_thread_id)); assert!(!app.thread_event_channels.contains_key(&side_thread_id)); assert!(app.active_thread_rx.is_some()); app_server.shutdown().await?; Ok(()) } #[tokio::test] async fn active_history_batch_is_delivered_without_replay_buffering() -> Result<()> { let mut app = make_test_app().await; let thread_id = ThreadId::new(); app.thread_event_channels .insert(thread_id, ThreadEventChannel::new(/*capacity*/ 4)); app.set_thread_active(thread_id, /*active*/ true).await; let cursor = codex_message_history::HistoryBatchCursor::new(/*end_offset*/ 1); let event = HistoryLookupResponse::Batch { cursor, log_id: 1, entries: vec![HistoryBatchEntryResponse { offset: 1, entry: Some("history entry".to_string()), }], next_older_cursor: Some(codex_message_history::HistoryBatchCursor::new( /*end_offset*/ 0, )), }; app.enqueue_thread_history_entry_response(thread_id, event.clone()) .await?; let channel = app .thread_event_channels .get_mut(&thread_id) .expect("missing thread channel"); assert!(channel.store.lock().await.buffer.is_empty()); let mut receiver = channel.receiver.take().expect("missing receiver"); let delivered = time::timeout(Duration::from_millis(50), receiver.recv()) .await .expect("timed out waiting for history batch") .expect("missing history batch"); let ThreadBufferedEvent::HistoryEntryResponse(delivered) = delivered else { panic!("expected history batch response"); }; assert_eq!(delivered, event); Ok(()) } #[tokio::test] async fn replay_thread_snapshot_renders_only_retained_agent_message_deltas() { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::new(); let session = test_thread_session(thread_id, test_path_buf("/tmp/project")); app.thread_event_channels.insert( thread_id, ThreadEventChannel::new_with_session( THREAD_EVENT_CHANNEL_CAPACITY, session.clone(), Vec::new(), ), ); app.activate_thread_channel(thread_id).await; app.chat_widget.handle_thread_session(session); { let channel = app .thread_event_channels .get(&thread_id) .expect("thread channel should exist"); let mut store = channel.store.lock().await; for chunk in 0..65 { let marker = format!("chunk {chunk:02}"); let text = format!("{marker}\n{}\n", "x".repeat(4096 - marker.len() - 2)); store.push_notification(agent_message_delta_notification( thread_id, "turn-budget", "item-budget", &text, )); } } app.store_active_thread_receiver().await; let (_receiver, snapshot) = app .activate_thread_for_replay(thread_id) .await .expect("detached thread should reactivate for replay"); app.replay_thread_snapshot(snapshot, /*resume_restored_queue*/ false); let mut rendered = Vec::new(); loop { app.chat_widget.on_commit_tick(); let mut inserted = false; while let Ok(event) = app_event_rx.try_recv() { if let AppEvent::InsertHistoryCell(cell) = event { rendered.push(lines_to_single_string(&cell.display_lines(/*width*/ 80))); inserted = true; } } if !inserted { break; } } if let Some(lines) = app.chat_widget.active_cell_transcript_lines(/*width*/ 80) { rendered.push(lines_to_single_string(&lines)); } let markers = rendered .iter() .flat_map(|text| text.lines()) .filter_map(|line| line.split_once("chunk ")) .map(|(_, marker)| format!("chunk {}", marker.trim())) .collect::>(); assert_eq!(markers.len(), 64); assert_snapshot!( format!("{}\n{}", markers.first().expect("first marker"), markers.last().expect("last marker")), @r" chunk 01 chunk 64 " ); } #[tokio::test] async fn replay_thread_snapshot_restores_draft_and_queued_input() { let mut app = make_test_app().await; let thread_id = ThreadId::new(); let session = test_thread_session(thread_id, test_path_buf("/tmp/project")); app.thread_event_channels.insert( thread_id, ThreadEventChannel::new_with_session( THREAD_EVENT_CHANNEL_CAPACITY, session.clone(), Vec::new(), ), ); app.activate_thread_channel(thread_id).await; app.chat_widget.handle_thread_session(session.clone()); app.chat_widget .apply_external_edit("draft prompt".to_string()); app.chat_widget.submit_user_message_with_mode( "queued follow-up".to_string(), CollaborationModeMask { name: "Default".to_string(), mode: None, model: None, reasoning_effort: None, developer_instructions: None, }, ); let expected_input_state = app .chat_widget .capture_thread_input_state() .expect("expected thread input state"); app.store_active_thread_receiver().await; let snapshot = { let channel = app .thread_event_channels .get(&thread_id) .expect("thread channel should exist"); let store = channel.store.lock().await; assert_eq!(store.input_state, Some(expected_input_state)); store.snapshot() }; let (chat_widget, _app_event_tx, _rx, mut new_op_rx) = make_chatwidget_manual_with_sender().await; app.chat_widget = chat_widget; let outgoing_thread_id = ThreadId::new(); app.chat_widget.handle_thread_session(test_thread_session( outgoing_thread_id, test_path_buf("/tmp/outgoing-project"), )); app.chat_widget.handle_server_notification( turn_started_notification(outgoing_thread_id, "outgoing-turn"), /*replay_kind*/ None, ); app.chat_widget .apply_external_edit("outgoing queued input".to_string()); app.chat_widget .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); app.chat_widget .set_queue_autosend_suppressed(/*suppressed*/ true); app.chat_widget.handle_server_notification( turn_completed_notification(outgoing_thread_id, "outgoing-turn", TurnStatus::Completed), /*replay_kind*/ None, ); app.chat_widget .set_queue_autosend_suppressed(/*suppressed*/ false); app.replay_thread_snapshot(snapshot, /*resume_restored_queue*/ true); assert_eq!(app.chat_widget.composer_text_with_pending(), "draft prompt"); assert!(app.chat_widget.queued_user_message_texts().is_empty()); while let Ok(op) = new_op_rx.try_recv() { assert!( !matches!(op, Op::UserTurn { .. }), "draft-only replay should not auto-submit queued input" ); } } #[tokio::test] async fn replay_thread_snapshot_restores_the_matching_safety_buffer_prompt() { let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::new(); let session = test_thread_session(thread_id, test_path_buf("/tmp/project")); app.thread_event_channels.insert( thread_id, ThreadEventChannel::new_with_session( THREAD_EVENT_CHANNEL_CAPACITY, session.clone(), Vec::new(), ), ); app.activate_thread_channel(thread_id).await; app.chat_widget.handle_thread_session(session); let default_mode = CollaborationModeMask { name: "Default".to_string(), mode: None, model: None, reasoning_effort: None, developer_instructions: None, }; app.chat_widget .submit_user_message_with_mode("buffered prompt A".to_string(), default_mode.clone()); let expected_input_state = app .chat_widget .capture_thread_input_state() .expect("expected thread input state"); app.store_active_thread_receiver().await; let snapshot = { let channel = app .thread_event_channels .get(&thread_id) .expect("thread channel should exist"); let store = channel.store.lock().await; assert_eq!(store.input_state, Some(expected_input_state.clone())); store.snapshot() }; let (mut chat_widget, _app_event_tx, _rx, _op_rx) = boxed_future!(make_chatwidget_manual_with_sender()).await; chat_widget.handle_thread_session(test_thread_session( ThreadId::new(), test_path_buf("/tmp/other-project"), )); chat_widget.submit_user_message_with_mode("buffered prompt B".to_string(), default_mode); app.chat_widget = chat_widget; app.replay_thread_snapshot(snapshot, /*resume_restored_queue*/ false); assert_eq!( app.chat_widget.capture_thread_input_state(), Some(expected_input_state) ); } #[tokio::test] async fn active_turn_id_for_thread_uses_snapshot_turns() { let mut app = make_test_app().await; let thread_id = ThreadId::new(); let session = test_thread_session(thread_id, test_path_buf("/tmp/project")); app.thread_event_channels.insert( thread_id, ThreadEventChannel::new_with_session( THREAD_EVENT_CHANNEL_CAPACITY, session, vec![test_turn("turn-1", TurnStatus::InProgress, Vec::new())], ), ); assert_eq!( app.active_turn_id_for_thread(thread_id).await, Some("turn-1".to_string()) ); } #[tokio::test] async fn replayed_turn_complete_submits_restored_queued_follow_up() { let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::new(); let session = test_thread_session(thread_id, test_path_buf("/tmp/project")); app.chat_widget.handle_thread_session(session.clone()); app.chat_widget.handle_server_notification( turn_started_notification(thread_id, "turn-1"), /*replay_kind*/ None, ); app.chat_widget.handle_server_notification( agent_message_delta_notification(thread_id, "turn-1", "agent-1", "streaming"), /*replay_kind*/ None, ); app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() .expect("expected queued follow-up state"); let (chat_widget, _app_event_tx, _rx, mut new_op_rx) = make_chatwidget_manual_with_sender().await; app.chat_widget = chat_widget; app.chat_widget.handle_thread_session(session.clone()); while new_op_rx.try_recv().is_ok() {} app.replay_thread_snapshot( ThreadEventSnapshot { delegated_turns: Vec::new(), session: None, turns: Vec::new(), events: vec![ThreadBufferedEvent::Notification(Box::new( turn_completed_notification(thread_id, "turn-1", TurnStatus::Completed), ))], active_reasoning_item: None, input_state: Some(input_state), }, /*resume_restored_queue*/ true, ); match next_user_turn_op(&mut new_op_rx) { Op::UserTurn { items, .. } => assert_eq!( items, vec![UserInput::Text { text: "queued follow-up".to_string(), text_elements: Vec::new(), }] ), other => panic!("expected queued follow-up submission, got {other:?}"), } } #[tokio::test] async fn replay_only_thread_keeps_restored_queue_visible() { let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::new(); let session = test_thread_session(thread_id, test_path_buf("/tmp/project")); app.thread_event_channels.insert( thread_id, ThreadEventChannel::new_with_session( THREAD_EVENT_CHANNEL_CAPACITY, session.clone(), Vec::new(), ), ); app.activate_thread_channel(thread_id).await; app.chat_widget.handle_thread_session(session.clone()); app.chat_widget.handle_server_notification( turn_started_notification(thread_id, "turn-1"), /*replay_kind*/ None, ); app.chat_widget.handle_server_notification( agent_message_delta_notification(thread_id, "turn-1", "agent-1", "streaming"), /*replay_kind*/ None, ); app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let mut input_state = app .chat_widget .capture_thread_input_state() .expect("expected queued follow-up state"); input_state.reconnect_pending = true; let (chat_widget, _app_event_tx, _rx, mut new_op_rx) = make_chatwidget_manual_with_sender().await; app.chat_widget = chat_widget; app.chat_widget.handle_thread_session(session.clone()); while new_op_rx.try_recv().is_ok() {} app.replay_thread_snapshot( ThreadEventSnapshot { delegated_turns: Vec::new(), session: None, turns: Vec::new(), events: vec![ThreadBufferedEvent::Notification(Box::new( turn_completed_notification(thread_id, "turn-1", TurnStatus::Completed), ))], active_reasoning_item: None, input_state: Some(input_state), }, /*resume_restored_queue*/ false, ); assert_eq!( app.chat_widget.queued_user_message_texts(), vec!["queued follow-up".to_string()] ); assert!( new_op_rx.try_recv().is_err(), "replay-only threads should not auto-submit restored queue" ); } #[tokio::test] async fn replay_thread_snapshot_keeps_queue_when_running_state_only_comes_from_snapshot() { let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::new(); let session = test_thread_session(thread_id, test_path_buf("/tmp/project")); app.chat_widget.handle_thread_session(session.clone()); app.chat_widget.handle_server_notification( turn_started_notification(thread_id, "turn-1"), /*replay_kind*/ None, ); app.chat_widget.handle_server_notification( agent_message_delta_notification(thread_id, "turn-1", "agent-1", "streaming"), /*replay_kind*/ None, ); app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() .expect("expected queued follow-up state"); let (chat_widget, _app_event_tx, _rx, mut new_op_rx) = make_chatwidget_manual_with_sender().await; app.chat_widget = chat_widget; app.chat_widget.handle_thread_session(session.clone()); while new_op_rx.try_recv().is_ok() {} app.replay_thread_snapshot( ThreadEventSnapshot { delegated_turns: Vec::new(), session: None, turns: Vec::new(), events: vec![], active_reasoning_item: None, input_state: Some(input_state), }, /*resume_restored_queue*/ true, ); assert_eq!( app.chat_widget.queued_user_message_texts(), vec!["queued follow-up".to_string()] ); assert!( new_op_rx.try_recv().is_err(), "restored queue should stay queued when replay did not prove the turn finished" ); } #[tokio::test] async fn replay_thread_snapshot_in_progress_turn_restores_running_queue_state() { let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::new(); let session = test_thread_session(thread_id, test_path_buf("/tmp/project")); app.chat_widget.handle_thread_session(session.clone()); app.chat_widget.handle_server_notification( turn_started_notification(thread_id, "turn-1"), /*replay_kind*/ None, ); app.chat_widget.handle_server_notification( agent_message_delta_notification(thread_id, "turn-1", "agent-1", "streaming"), /*replay_kind*/ None, ); app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() .expect("expected queued follow-up state"); let (chat_widget, _app_event_tx, _rx, mut new_op_rx) = make_chatwidget_manual_with_sender().await; app.chat_widget = chat_widget; app.chat_widget.handle_thread_session(session.clone()); while new_op_rx.try_recv().is_ok() {} app.replay_thread_snapshot( ThreadEventSnapshot { delegated_turns: Vec::new(), session: None, turns: vec![test_turn("turn-1", TurnStatus::InProgress, Vec::new())], events: Vec::new(), active_reasoning_item: None, input_state: Some(input_state), }, /*resume_restored_queue*/ true, ); assert_eq!( app.chat_widget.queued_user_message_texts(), vec!["queued follow-up".to_string()] ); assert!( new_op_rx.try_recv().is_err(), "restored queue should stay queued while replayed turn is still running" ); } #[tokio::test] async fn replay_thread_snapshot_in_progress_turn_restores_running_state_without_input_state() { let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::new(); let session = test_thread_session(thread_id, test_path_buf("/tmp/project")); let (chat_widget, _app_event_tx, _rx, _new_op_rx) = boxed_future!(make_chatwidget_manual_with_sender()).await; app.chat_widget = chat_widget; app.chat_widget.handle_thread_session(session); app.replay_thread_snapshot( ThreadEventSnapshot { delegated_turns: Vec::new(), session: None, turns: vec![test_turn("turn-1", TurnStatus::InProgress, Vec::new())], events: Vec::new(), active_reasoning_item: None, input_state: None, }, /*resume_restored_queue*/ false, ); assert!(app.chat_widget.is_task_running_for_test()); } #[tokio::test] async fn replay_thread_snapshot_does_not_submit_queue_before_replay_catches_up() { let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::new(); let session = test_thread_session(thread_id, test_path_buf("/tmp/project")); app.chat_widget.handle_thread_session(session.clone()); app.chat_widget.handle_server_notification( turn_started_notification(thread_id, "turn-1"), /*replay_kind*/ None, ); app.chat_widget.handle_server_notification( agent_message_delta_notification(thread_id, "turn-1", "agent-1", "streaming"), /*replay_kind*/ None, ); app.chat_widget .apply_external_edit("accepted steer".to_string()); app.chat_widget .handle_key_event(KeyEvent::from(KeyCode::Enter)); app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let mut input_state = app .chat_widget .capture_thread_input_state() .expect("expected queued follow-up state"); let client_id = input_state .pending_steers .front() .unwrap() .client_id .clone(); input_state.reconnect_pending = true; let (chat_widget, _app_event_tx, _rx, mut new_op_rx) = make_chatwidget_manual_with_sender().await; app.chat_widget = chat_widget; app.chat_widget.handle_thread_session(session.clone()); while new_op_rx.try_recv().is_ok() {} app.replay_thread_snapshot( ThreadEventSnapshot { delegated_turns: Vec::new(), session: None, turns: Vec::new(), events: vec![ ThreadBufferedEvent::Notification(Box::new(ServerNotification::ItemCompleted( codex_app_server_protocol::ItemCompletedNotification::new( thread_id.to_string(), "turn-1".into(), ThreadItemPayload::UserMessage { id: "accepted".into(), client_id: Some(client_id), content: vec![UserInput::Text { text: "accepted steer".into(), text_elements: Vec::new(), }], }, /*started_at_ms*/ None, /*completed_at_ms*/ 0, ), ))), ThreadBufferedEvent::Notification(Box::new(turn_completed_notification( thread_id, "turn-0", TurnStatus::Completed, ))), ThreadBufferedEvent::Notification(Box::new(turn_started_notification( thread_id, "turn-1", ))), ], active_reasoning_item: None, input_state: Some(input_state), }, /*resume_restored_queue*/ true, ); assert!( new_op_rx.try_recv().is_err(), "queued follow-up should stay queued until the latest turn completes" ); assert_eq!( app.chat_widget.queued_user_message_texts(), vec!["queued follow-up".to_string()] ); app.chat_widget.handle_server_notification( turn_completed_notification(thread_id, "turn-1", TurnStatus::Completed), /*replay_kind*/ None, ); match next_user_turn_op(&mut new_op_rx) { Op::UserTurn { items, .. } => assert_eq!( items, vec![UserInput::Text { text: "queued follow-up".to_string(), text_elements: Vec::new(), }] ), other => panic!("expected queued follow-up submission, got {other:?}"), } } #[tokio::test] async fn replay_thread_snapshot_restores_pending_pastes_for_submit() { let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::new(); let session = test_thread_session(thread_id, test_path_buf("/tmp/project")); app.thread_event_channels.insert( thread_id, ThreadEventChannel::new_with_session( THREAD_EVENT_CHANNEL_CAPACITY, session.clone(), Vec::new(), ), ); app.activate_thread_channel(thread_id).await; app.chat_widget.handle_thread_session(session); let large = "x".repeat(1005); app.chat_widget.handle_paste(large.clone()); let expected_input_state = app .chat_widget .capture_thread_input_state() .expect("expected thread input state"); app.store_active_thread_receiver().await; let snapshot = { let channel = app .thread_event_channels .get(&thread_id) .expect("thread channel should exist"); let store = channel.store.lock().await; assert_eq!(store.input_state, Some(expected_input_state)); store.snapshot() }; let (chat_widget, _app_event_tx, _rx, mut new_op_rx) = make_chatwidget_manual_with_sender().await; app.chat_widget = chat_widget; app.replay_thread_snapshot(snapshot, /*resume_restored_queue*/ true); assert_eq!(app.chat_widget.composer_text_with_pending(), large); app.chat_widget .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match next_user_turn_op(&mut new_op_rx) { Op::UserTurn { items, .. } => assert_eq!( items, vec![UserInput::Text { text: large, text_elements: Vec::new(), }] ), other => panic!("expected restored paste submission, got {other:?}"), } } #[tokio::test] async fn replay_thread_snapshot_restores_collaboration_mode_for_draft_submit() { let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::new(); let session = test_thread_session(thread_id, test_path_buf("/tmp/project")); app.chat_widget.handle_thread_session(session.clone()); app.chat_widget .set_reasoning_effort(Some(ReasoningEffortConfig::High)); app.chat_widget .set_collaboration_mask(CollaborationModeMask { name: "Plan".to_string(), mode: Some(ModeKind::Plan), model: Some("gpt-restored".to_string()), reasoning_effort: Some(Some(ReasoningEffortConfig::High)), developer_instructions: None, }); app.chat_widget .apply_external_edit("draft prompt".to_string()); let input_state = app .chat_widget .capture_thread_input_state() .expect("expected draft input state"); let (chat_widget, _app_event_tx, _rx, mut new_op_rx) = make_chatwidget_manual_with_sender().await; app.chat_widget = chat_widget; app.chat_widget.handle_thread_session(session.clone()); app.chat_widget .set_reasoning_effort(Some(ReasoningEffortConfig::Low)); app.chat_widget .set_collaboration_mask(CollaborationModeMask { name: "Default".to_string(), mode: Some(ModeKind::Default), model: Some("gpt-replacement".to_string()), reasoning_effort: Some(Some(ReasoningEffortConfig::Low)), developer_instructions: None, }); while new_op_rx.try_recv().is_ok() {} app.replay_thread_snapshot( ThreadEventSnapshot { delegated_turns: Vec::new(), session: None, turns: Vec::new(), events: vec![], active_reasoning_item: None, input_state: Some(input_state), }, /*resume_restored_queue*/ true, ); app.chat_widget .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match next_user_turn_op(&mut new_op_rx) { Op::UserTurn { items, model, effort, collaboration_mode, .. } => { assert_eq!( items, vec![UserInput::Text { text: "draft prompt".to_string(), text_elements: Vec::new(), }] ); assert_eq!(model, "gpt-restored".to_string()); assert_eq!(effort, Some(ReasoningEffortConfig::High)); assert_eq!( collaboration_mode, Some(CollaborationMode { mode: ModeKind::Plan, settings: Settings { model: "gpt-restored".to_string(), reasoning_effort: Some(ReasoningEffortConfig::High), developer_instructions: None, }, }) ); } other => panic!("expected restored draft submission, got {other:?}"), } } #[tokio::test] async fn replay_thread_snapshot_restores_collaboration_mode_without_input() { let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::new(); let session = test_thread_session(thread_id, test_path_buf("/tmp/project")); app.chat_widget.handle_thread_session(session.clone()); app.chat_widget .set_reasoning_effort(Some(ReasoningEffortConfig::High)); app.chat_widget .set_collaboration_mask(CollaborationModeMask { name: "Plan".to_string(), mode: Some(ModeKind::Plan), model: Some("gpt-restored".to_string()), reasoning_effort: Some(Some(ReasoningEffortConfig::High)), developer_instructions: None, }); app.chat_widget .set_plan_mode_reasoning_effort(Some(ReasoningEffortConfig::High)); let input_state = app .chat_widget .capture_thread_input_state() .expect("expected collaboration-only input state"); let (chat_widget, _app_event_tx, _rx, _new_op_rx) = boxed_future!(make_chatwidget_manual_with_sender()).await; app.chat_widget = chat_widget; app.chat_widget .set_plan_mode_reasoning_effort(Some(ReasoningEffortConfig::Low)); app.chat_widget.handle_thread_session(session.clone()); app.chat_widget .set_reasoning_effort(Some(ReasoningEffortConfig::Low)); app.chat_widget .set_collaboration_mask(CollaborationModeMask { name: "Default".to_string(), mode: Some(ModeKind::Default), model: Some("gpt-replacement".to_string()), reasoning_effort: Some(Some(ReasoningEffortConfig::Low)), developer_instructions: None, }); app.replay_thread_snapshot( ThreadEventSnapshot { delegated_turns: Vec::new(), session: None, turns: Vec::new(), events: vec![], active_reasoning_item: None, input_state: Some(input_state), }, /*resume_restored_queue*/ true, ); assert_eq!( app.chat_widget.active_collaboration_mode_kind(), ModeKind::Plan ); assert_eq!(app.chat_widget.current_model(), "gpt-restored"); assert_eq!( app.chat_widget.current_reasoning_effort(), Some(ReasoningEffortConfig::High) ); for _ in 0..2 { app.chat_widget .handle_key_event(KeyEvent::from(KeyCode::BackTab)); } assert_eq!( app.chat_widget.current_reasoning_effort(), Some(ReasoningEffortConfig::High) ); } #[tokio::test] async fn replayed_interrupted_turn_restores_queued_input_to_composer() { let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::new(); let session = test_thread_session(thread_id, test_path_buf("/tmp/project")); app.chat_widget.handle_thread_session(session.clone()); app.chat_widget.handle_server_notification( turn_started_notification(thread_id, "turn-1"), /*replay_kind*/ None, ); app.chat_widget.handle_server_notification( agent_message_delta_notification(thread_id, "turn-1", "agent-1", "streaming"), /*replay_kind*/ None, ); app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() .expect("expected queued follow-up state"); let (chat_widget, _app_event_tx, _rx, mut new_op_rx) = make_chatwidget_manual_with_sender().await; app.chat_widget = chat_widget; app.chat_widget.handle_thread_session(session.clone()); while new_op_rx.try_recv().is_ok() {} app.replay_thread_snapshot( ThreadEventSnapshot { delegated_turns: Vec::new(), session: None, turns: Vec::new(), events: vec![ThreadBufferedEvent::Notification(Box::new( turn_completed_notification(thread_id, "turn-1", TurnStatus::Interrupted), ))], active_reasoning_item: None, input_state: Some(input_state), }, /*resume_restored_queue*/ true, ); assert_eq!( app.chat_widget.composer_text_with_pending(), "queued follow-up" ); assert!(app.chat_widget.queued_user_message_texts().is_empty()); assert!( new_op_rx.try_recv().is_err(), "replayed interrupted turns should restore queued input for editing, not submit it" ); } #[tokio::test] async fn token_usage_update_refreshes_status_line_with_runtime_context_window() { use crate::bottom_pane::StatusLineItem; use crate::bottom_pane::footer_content::FooterContent; let mut app = make_test_app().await; app.chat_widget.setup_status_line( vec![ StatusLineItem::ContextWindowSize, StatusLineItem::UsedTokens, ], /*use_theme_colors*/ true, ); assert_eq!( app.chat_widget.config_panel_tui.status_line, app.chat_widget.local_settings.tui.status_line ); assert!(app.chat_widget.config_panel_tui.status_line_use_colors); assert_eq!(app.chat_widget.status_line_text(), None); let thread_id = ThreadId::new(); for (extensions, window, expected) in [ (false, 950_000, "950K window · 8 used"), (true, 800_000, "800K window · 8 used"), (false, 900_000, "900K window · 8 used"), ] { let before = render_bottom_popup(&app.chat_widget, /*width*/ 60); app.chat_widget .set_feature_enabled(Feature::TuiExtensionsStatusLine, extensions); app.refresh_status_line(); assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 60), before); app.handle_thread_event_now(ThreadBufferedEvent::Notification(Box::new( token_usage_notification(thread_id, "turn-1", Some(window)), ))); assert!(matches!( (extensions, app.chat_widget.status_line_content()), (false, Some(FooterContent::HyperlinkLine(_))) | (true, Some(FooterContent::Extension { .. })) )); let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 60); assert!( rendered.lines().any(|row| row.trim() == expected), "{rendered}" ); } } #[tokio::test] async fn collab_receiver_notification_caches_thread_without_app_server_read() { let mut app = make_test_app().await; let receiver_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread id"); app.handle_thread_event_now(ThreadBufferedEvent::Notification(Box::new( ServerNotification::ItemStarted(ItemStartedNotification::new( ThreadId::new().to_string(), "turn-1".to_string(), ThreadItemPayload::CollabAgentToolCall { id: "wait-1".to_string(), tool: codex_app_server_protocol::CollabAgentTool::Wait, status: codex_app_server_protocol::CollabAgentToolCallStatus::InProgress, sender_thread_id: ThreadId::new().to_string(), receiver_thread_ids: vec![receiver_thread_id.to_string()], prompt: None, model: None, reasoning_effort: None, agents_states: HashMap::new(), }, /*started_at_ms*/ 0, )), ))); assert_eq!( app.agent_navigation.get(&receiver_thread_id), Some(&AgentPickerThreadEntry { agent_nickname: None, agent_role: None, agent_path: None, is_running: false, is_closed: false, }) ); } #[tokio::test] async fn collab_receiver_notification_does_not_cache_not_found_thread() { let mut app = make_test_app().await; let receiver_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000124").expect("valid thread id"); app.handle_thread_event_now(ThreadBufferedEvent::Notification(Box::new( ServerNotification::ItemCompleted( codex_app_server_protocol::ItemCompletedNotification::new( ThreadId::new().to_string(), "turn-1".to_string(), ThreadItemPayload::CollabAgentToolCall { id: "send-1".to_string(), tool: codex_app_server_protocol::CollabAgentTool::SendInput, status: codex_app_server_protocol::CollabAgentToolCallStatus::Failed, sender_thread_id: ThreadId::new().to_string(), receiver_thread_ids: vec![receiver_thread_id.to_string()], prompt: Some("hello".to_string()), model: None, reasoning_effort: None, agents_states: HashMap::from([( receiver_thread_id.to_string(), codex_app_server_protocol::CollabAgentState { status: codex_app_server_protocol::CollabAgentStatus::NotFound, message: None, }, )]), }, /*started_at_ms*/ None, /*completed_at_ms*/ 0, ), ), ))); assert_eq!(app.agent_navigation.get(&receiver_thread_id), None); } #[tokio::test] async fn archived_untracked_threads_do_not_appear_in_agent_picker() -> Result<()> { let mut app = Box::pin(make_test_app()).await; let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( app.chat_widget.config_ref(), )) .await?; let primary_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread id"); app.enqueue_primary_thread_session( test_thread_session(primary_thread_id, test_path_buf("/tmp/project")), Vec::new(), ) .await?; let archived_thread_id = ThreadId::new(); app.handle_app_server_event( &app_server, codex_app_server_client::AppServerEvent::ServerNotification(Box::new( ServerNotification::ThreadArchived(ThreadArchivedNotification { thread_id: archived_thread_id.to_string(), }), )), ) .await; assert!(!app.thread_event_channels.contains_key(&archived_thread_id)); let attachment_thread_id = ThreadId::new(); app.handle_app_server_event( &app_server, codex_app_server_client::AppServerEvent::ServerNotification(Box::new( ServerNotification::ThreadAttachmentUpdated(ThreadAttachmentUpdatedNotification { thread_id: attachment_thread_id.to_string(), attachment_type: "pull_request".to_string(), identity_key: r#"["github.com","openai","codex",123]"#.to_string(), attachment_id: "attachment-1".to_string(), operation: ThreadAttachmentOperation::Deleted, }), )), ) .await; assert!( !app.thread_event_channels .contains_key(&attachment_thread_id) ); app.upsert_agent_picker_thread( archived_thread_id, Some("Missed archive".to_string()), Some("worker".to_string()), /*is_closed*/ false, ); app.thread_event_channels .insert(archived_thread_id, ThreadEventChannel::new(/*capacity*/ 1)); let request_id = app .agent_navigation .begin_picker_refresh(primary_thread_id) .expect("picker refresh after missed archive"); app.apply_agent_picker_thread_refresh( &app_server, primary_thread_id, request_id, Ok(crate::app_event::AgentPickerThreadRefresh { threads: Vec::new(), archived_thread_ids: std::collections::HashSet::from([archived_thread_id]), }), ); assert_eq!( app.thread_event_channels[&archived_thread_id].attachment(), ThreadEventAttachment::ReplayOnly ); Box::pin(app.open_agent_picker(&mut app_server)).await; let archived_picker = render_bottom_popup(&app.chat_widget, /*width*/ 80); app.handle_app_server_event( &app_server, codex_app_server_client::AppServerEvent::ServerNotification(Box::new( ServerNotification::ThreadUnarchived(ThreadUnarchivedNotification { thread_id: archived_thread_id.to_string(), }), )), ) .await; assert_app_snapshot!( "untracked_thread_notifications_agent_picker", format!( "Archived:\n{archived_picker}\nUnarchived:\n{}", render_bottom_popup(&app.chat_widget, /*width*/ 80) ) .replace(&archived_thread_id.to_string(), "[archived]") ); assert_eq!( app.agent_navigation.ordered_thread_ids(), vec![primary_thread_id, archived_thread_id] ); assert_eq!(app.active_thread_id, Some(primary_thread_id)); Ok(()) } #[tokio::test] async fn open_agent_picker_keeps_missing_threads_for_replay() -> Result<()> { let mut app = Box::pin(make_test_app()).await; let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( app.chat_widget.config_ref(), )) .await .expect("embedded app server"); let thread_id = ThreadId::new(); app.thread_event_channels .insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1)); Box::pin(app.open_agent_picker(&mut app_server)).await; assert_eq!(app.thread_event_channels.contains_key(&thread_id), true); assert_eq!( app.agent_navigation.get(&thread_id), Some(&AgentPickerThreadEntry { agent_nickname: None, agent_role: None, agent_path: None, is_running: false, is_closed: true, }) ); assert_eq!(app.agent_navigation.ordered_thread_ids(), vec![thread_id]); Ok(()) } #[tokio::test] async fn open_agent_picker_preserves_cached_metadata_for_replay_threads() -> Result<()> { let mut app = Box::pin(make_test_app()).await; let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( app.chat_widget.config_ref(), )) .await .expect("embedded app server"); let thread_id = ThreadId::new(); app.thread_event_channels .insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1)); app.agent_navigation.upsert( thread_id, Some("Robie".to_string()), Some("explorer".to_string()), /*is_closed*/ true, ); Box::pin(app.open_agent_picker(&mut app_server)).await; assert_eq!(app.thread_event_channels.contains_key(&thread_id), true); assert_eq!( app.agent_navigation.get(&thread_id), Some(&AgentPickerThreadEntry { agent_nickname: Some("Robie".to_string()), agent_role: Some("explorer".to_string()), agent_path: None, is_running: false, is_closed: true, }) ); Ok(()) } #[tokio::test] async fn open_agent_picker_preserves_running_hints_until_observed_completion() -> Result<()> { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( app.chat_widget.config_ref(), )) .await .expect("embedded app server"); let thread_id = ThreadId::new(); app.thread_event_channels .insert(thread_id, ThreadEventChannel::new(/*capacity*/ 4)); app.agent_navigation .record_sub_agent_activity(SubAgentActivityDisplay { thread_id, agent_path: "/root/child".to_string(), is_running_hint: true, }); Box::pin(app.open_agent_picker(&mut app_server)).await; let mut expected_entry = AgentPickerThreadEntry { agent_nickname: None, agent_role: None, agent_path: Some("/root/child".to_string()), is_running: true, is_closed: false, }; assert_eq!(app.agent_navigation.get(&thread_id), Some(&expected_entry)); let status = loop { let event = app_event_rx.try_recv().expect("agent status history cell"); if let AppEvent::InsertHistoryCell(cell) = event { let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80)); if rendered.contains("/subagents") { break rendered; } } }; assert_snapshot!(status, @r###" /subagents Sub-agents running • `/root/child` No recent activity yet. "###); app.enqueue_thread_notification( thread_id, turn_completed_notification(thread_id, "turn-older", TurnStatus::Completed), ) .await?; Box::pin(app.open_agent_picker(&mut app_server)).await; assert_eq!(app.agent_navigation.get(&thread_id), Some(&expected_entry)); app.enqueue_thread_notification(thread_id, turn_started_notification(thread_id, "turn-1")) .await?; app.enqueue_thread_notification( thread_id, turn_completed_notification(thread_id, "turn-1", TurnStatus::Completed), ) .await?; Box::pin(app.open_agent_picker(&mut app_server)).await; expected_entry.is_running = false; assert_eq!(app.agent_navigation.get(&thread_id), Some(&expected_entry)); app.agent_navigation .record_sub_agent_activity(SubAgentActivityDisplay { thread_id, agent_path: "/root/child".to_string(), is_running_hint: true, }); Box::pin(app.open_agent_picker(&mut app_server)).await; expected_entry.is_running = false; assert_eq!(app.agent_navigation.get(&thread_id), Some(&expected_entry)); app.enqueue_thread_notification(thread_id, turn_started_notification(thread_id, "turn-2")) .await?; Box::pin(app.open_agent_picker(&mut app_server)).await; expected_entry.is_running = true; assert_eq!(app.agent_navigation.get(&thread_id), Some(&expected_entry)); Ok(()) } #[tokio::test] async fn open_agent_picker_clears_running_hint_from_completed_snapshot() -> Result<()> { let mut app = Box::pin(make_test_app()).await; let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( app.chat_widget.config_ref(), )) .await .expect("embedded app server"); let thread_id = ThreadId::new(); app.thread_event_channels.insert( thread_id, ThreadEventChannel::new_with_session( THREAD_EVENT_CHANNEL_CAPACITY, test_thread_session(thread_id, test_path_buf("/tmp/project")), vec![test_turn("turn-1", TurnStatus::Completed, Vec::new())], ), ); app.agent_navigation .record_sub_agent_activity(SubAgentActivityDisplay { thread_id, agent_path: "/root/child".to_string(), is_running_hint: true, }); assert!(!app.agent_navigation.is_parent_owned(thread_id)); Box::pin(app.open_agent_picker(&mut app_server)).await; assert_eq!( app.agent_navigation.get(&thread_id), Some(&AgentPickerThreadEntry { agent_nickname: None, agent_role: None, agent_path: Some("/root/child".to_string()), is_running: false, is_closed: false, }) ); Ok(()) } #[tokio::test] async fn open_agent_picker_selects_path_backed_agent() -> Result<()> { let (mut app, mut app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await; let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( app.chat_widget.config_ref(), )) .await .expect("embedded app server"); let thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread id"); app.thread_event_channels .insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1)); app.agent_navigation .record_sub_agent_activity(SubAgentActivityDisplay { thread_id, agent_path: "/root/worker".to_string(), is_running_hint: true, }); Box::pin(app.open_agent_picker(&mut app_server)).await; assert_app_snapshot!( "path_backed_agent_picker", render_bottom_popup(&app.chat_widget, /*width*/ 80) ); while app_event_rx.try_recv().is_ok() {} app.chat_widget .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_matches!( app_event_rx.try_recv(), Ok(AppEvent::SelectAgentThread(selected_thread_id)) if selected_thread_id == thread_id ); Ok(()) } #[tokio::test] async fn open_agent_picker_refreshes_replay_only_path_backed_liveness() -> Result<()> { let mut app = Box::pin(make_test_app()).await; let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( app.chat_widget.config_ref(), )) .await .expect("embedded app server"); let thread_id = ThreadId::new(); let mut channel = ThreadEventChannel::new(/*capacity*/ 4); channel.mark_replay_only(); { let mut store = channel.store.lock().await; store.push_notification(turn_started_notification(thread_id, "turn-1")); } app.thread_event_channels.insert(thread_id, channel); app.agent_navigation .record_sub_agent_activity(SubAgentActivityDisplay { thread_id, agent_path: "/root/child".to_string(), is_running_hint: true, }); Box::pin(app.open_agent_picker(&mut app_server)).await; assert_eq!( app.agent_navigation.get(&thread_id), Some(&AgentPickerThreadEntry { agent_nickname: None, agent_role: None, agent_path: Some("/root/child".to_string()), is_running: false, is_closed: true, }) ); Ok(()) } #[tokio::test] async fn open_agent_picker_prunes_terminal_metadata_only_threads() -> Result<()> { let mut app = Box::pin(make_test_app()).await; let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( app.chat_widget.config_ref(), )) .await .expect("embedded app server"); let thread_id = ThreadId::new(); app.agent_navigation.upsert( thread_id, Some("Ghost".to_string()), Some("worker".to_string()), /*is_closed*/ false, ); Box::pin(app.open_agent_picker(&mut app_server)).await; assert_eq!(app.agent_navigation.get(&thread_id), None); assert!(app.agent_navigation.is_empty()); Ok(()) } #[tokio::test] async fn open_agent_picker_marks_terminal_read_errors_closed() -> Result<()> { let mut app = Box::pin(make_test_app()).await; let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( app.chat_widget.config_ref(), )) .await .expect("embedded app server"); let thread_id = ThreadId::new(); app.thread_event_channels .insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1)); app.agent_navigation.upsert( thread_id, Some("Robie".to_string()), Some("explorer".to_string()), /*is_closed*/ false, ); Box::pin(app.open_agent_picker(&mut app_server)).await; assert_eq!( app.agent_navigation.get(&thread_id), Some(&AgentPickerThreadEntry { agent_nickname: Some("Robie".to_string()), agent_role: Some("explorer".to_string()), agent_path: None, is_running: false, is_closed: true, }) ); Ok(()) } #[test] fn open_agent_picker_marks_loaded_threads_open() -> Result<()> { const WORKER_THREADS: usize = 1; const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024; let runtime = tokio::runtime::Builder::new_multi_thread() .worker_threads(WORKER_THREADS) .thread_stack_size(TEST_STACK_SIZE_BYTES) .enable_all() .build()?; runtime.block_on(async { let mut app = Box::pin(make_test_app()).await; let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( app.chat_widget.config_ref(), )) .await .expect("embedded app server"); let started = app_server .start_thread(app.chat_widget.config_ref()) .await?; let thread_id = started.session.thread_id; app.thread_event_channels .insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1)); Box::pin(app.open_agent_picker(&mut app_server)).await; assert_eq!( app.agent_navigation.get(&thread_id), Some(&AgentPickerThreadEntry { agent_nickname: None, agent_role: None, agent_path: None, is_running: false, is_closed: false, }) ); Ok(()) }) } #[test] fn selected_and_resumed_threads_use_server_capability_for_v1_and_v2_children() -> Result<()> { const WORKER_THREADS: usize = 1; const TEST_STACK_SIZE_BYTES: usize = 12 * 1024 * 1024; let runtime = tokio::runtime::Builder::new_multi_thread() .worker_threads(WORKER_THREADS) .thread_stack_size(TEST_STACK_SIZE_BYTES) .enable_all() .build()?; // Keep the scenario and its large setup/resume futures off the test thread's stack. runtime.block_on(Box::pin(async { let (mut app, mut app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await; let root_thread_id = ThreadId::new(); let rollout_dir = app .config .codex_home .join("sessions") .join("2026") .join("01") .join("01"); std::fs::create_dir_all(&rollout_dir)?; let root_session_meta = SessionMeta { session_id: root_thread_id.into(), id: root_thread_id, timestamp: "2026-01-01T00:00:00Z".to_string(), cwd: app.config.cwd.to_path_buf(), originator: "codex-tui-test".to_string(), cli_version: "0.0.0".to_string(), source: RolloutSessionSource::Cli, model_provider: Some(app.config.model_provider_id.clone()), multi_agent_version: Some(MultiAgentVersion::V2), ..SessionMeta::default() }; let root_session_meta_line = serde_json::json!({ "timestamp": "2026-01-01T00:00:00Z", "type": "session_meta", "payload": serde_json::to_value(root_session_meta)?, }); std::fs::write( rollout_dir.join(format!( "rollout-2026-01-01T00-00-00-{root_thread_id}.jsonl" )), format!("{root_session_meta_line}\n"), )?; let mut child_thread_ids = Vec::new(); for (index, multi_agent_version) in [MultiAgentVersion::V1, MultiAgentVersion::V2] .into_iter() .enumerate() { let child_thread_id = ThreadId::new(); let timestamp = format!("2026-01-01T00:00:0{index}Z"); let session_meta = SessionMeta { session_id: root_thread_id.into(), id: child_thread_id, parent_thread_id: Some(root_thread_id), timestamp: timestamp.clone(), cwd: app.config.cwd.to_path_buf(), originator: "codex-tui-test".to_string(), cli_version: "0.0.0".to_string(), source: RolloutSessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: root_thread_id, depth: 1, agent_path: None, agent_nickname: Some(format!("child-{index}")), agent_role: Some("worker".to_string()), }), model_provider: Some(app.config.model_provider_id.clone()), multi_agent_version: Some(multi_agent_version), ..SessionMeta::default() }; let rollout_path = rollout_dir.join(format!( "rollout-2026-01-01T00-00-0{index}-{child_thread_id}.jsonl" )); let session_meta_line = serde_json::json!({ "timestamp": timestamp, "type": "session_meta", "payload": serde_json::to_value(session_meta)?, }); let saved_message = format!("Saved work from child-{index}"); let response_item = serde_json::json!({ "timestamp": timestamp, "type": "response_item", "payload": { "type": "message", "role": "user", "content": [{"type": "input_text", "text": saved_message}] } }); let user_message = serde_json::json!({ "timestamp": timestamp, "type": "event_msg", "payload": {"type": "user_message", "message": saved_message, "kind": "plain"} }); std::fs::write( rollout_path, format!("{session_meta_line}\n{response_item}\n{user_message}\n"), )?; child_thread_ids.push(child_thread_id); } // Cold-resume the persisted V2 root so its children are registered before selection. std::fs::write( app.config.codex_home.join("config.toml"), "thread_unload_delay_secs = 0\n", )?; let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( &app.config, )) .await?; let root = Box::pin(app_server.resume_thread( &app.local_settings, app.config.clone(), root_thread_id, crate::app_server_session::ResumeModelSettings::PreserveExistingThread, )) .await?; app.enqueue_primary_thread_session(root.session, root.turns) .await?; let mut tui = crate::tui::test_support::make_test_tui()?; for (index, (child_thread_id, multi_agent_version)) in child_thread_ids .iter() .copied() .zip([MultiAgentVersion::V1, MultiAgentVersion::V2]) .enumerate() { while app_event_rx.try_recv().is_ok() {} assert_eq!( app_server.thread_read(child_thread_id, /*include_turns*/ false).await?.status, codex_app_server_protocol::ThreadStatus::NotLoaded ); app.select_agent_thread(&mut tui, &mut app_server, child_thread_id) .await?; assert_eq!(app.active_thread_id, Some(child_thread_id)); assert_eq!( app.agent_navigation.is_parent_owned(child_thread_id), multi_agent_version == MultiAgentVersion::V2 ); assert_app_snapshot!( format!("selected_unloaded_subagent_{multi_agent_version:?}"), normalize_snapshot_paths(render_bottom_popup(&app.chat_widget, /*width*/ 80)) ); let saved_message = format!("Saved work from child-{index}"); while let Ok(event) = app_event_rx.try_recv() { if let AppEvent::InsertHistoryCell(cell) = event { app.transcript_cells.push(Arc::from(cell)); } } assert!( app.render_transcript_lines_for_reflow(/*width*/ 80) .lines .iter() .any(|line| rendered_line_text(line).contains(&saved_message)) ); app.select_agent_thread(&mut tui, &mut app_server, root_thread_id) .await?; app_server.thread_unsubscribe(child_thread_id).await?; let closed = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), async { while let Some(event) = app_server.next_event().await { if matches!(&event.event, codex_app_server_client::AppServerEvent::ServerNotification(notification) if matches!(notification.as_ref(), ServerNotification::ThreadClosed(closed) if closed.thread_id == child_thread_id.to_string())) { return event; } } panic!("server event stream ended before the child closed"); }) .await?; while app_event_rx.try_recv().is_ok() {} app.select_agent_thread(&mut tui, &mut app_server, child_thread_id) .await?; // A queued close from the old runtime must not undo the new attachment. app.handle_backend_event(&app_server, closed).await; app.drain_active_thread_events(&mut tui).await?; assert_eq!(app.active_thread_id, Some(child_thread_id)); assert!(!matches!( app_server.thread_read(child_thread_id, /*include_turns*/ false).await?.status, codex_app_server_protocol::ThreadStatus::NotLoaded )); while let Ok(event) = app_event_rx.try_recv() { if let AppEvent::InsertHistoryCell(cell) = event { app.transcript_cells.push(Arc::from(cell)); } } assert!( app.render_transcript_lines_for_reflow(/*width*/ 80) .lines .iter() .any(|line| rendered_line_text(line).contains(&saved_message)) ); } app.select_agent_thread(&mut tui, &mut app_server, root_thread_id) .await?; app.agent_navigation .record_sub_agent_activity(SubAgentActivityDisplay { thread_id: child_thread_ids[0], agent_path: "/root/child-0".to_string(), is_running_hint: true, }); app.thread_event_channels.remove(&child_thread_ids[1]); let backfill = app.backfill_loaded_subagent_threads(&mut app_server).await; assert!(backfill.completed); assert_eq!( backfill.refreshed_thread_ids, [child_thread_ids[1]].into_iter().collect() ); assert_eq!( app.agent_navigation.get(&child_thread_ids[0]), Some(&AgentPickerThreadEntry { agent_nickname: Some("child-0".to_string()), agent_role: Some("worker".to_string()), agent_path: Some("/root/child-0".to_string()), is_running: true, is_closed: false, }) ); assert!(!app.agent_navigation.is_parent_owned(child_thread_ids[0])); assert!(app.agent_navigation.is_parent_owned(child_thread_ids[1])); app.select_agent_thread(&mut tui, &mut app_server, child_thread_ids[0]) .await?; while app_event_rx.try_recv().is_ok() {} app.chat_widget .restore_user_message_to_composer("v1 remains writable".into()); app.chat_widget .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!( std::iter::from_fn(|| app_event_rx.try_recv().ok()) .any(|event| matches!(event, AppEvent::CodexOp(Op::UserTurn { .. }))) ); app.select_agent_thread(&mut tui, &mut app_server, child_thread_ids[1]) .await?; while app_event_rx.try_recv().is_ok() {} app.chat_widget .restore_user_message_to_composer("v2 stays view-only".into()); let draft = app.chat_widget.composer_text_with_pending(); app.chat_widget .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!(app.chat_widget.composer_text_with_pending(), draft); assert!( !std::iter::from_fn(|| app_event_rx.try_recv().ok()) .any(|event| matches!(event, AppEvent::CodexOp(Op::UserTurn { .. }))) ); let resumed = Box::pin(app_server.resume_thread( &app.local_settings, app.config.clone(), child_thread_ids[1], app.resume_model_settings(), )) .await?; assert!(resumed.blocks_direct_input); app.replace_chat_widget_with_app_server_thread( &mut tui, resumed, crate::app::session_lifecycle::ThreadAttachPresentation::SessionLineage, /*initial_user_message*/ None, ) .await?; while app_event_rx.try_recv().is_ok() {} app.chat_widget .restore_user_message_to_composer("direct resume stays view-only".into()); let draft = app.chat_widget.composer_text_with_pending(); app.chat_widget .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!(app.chat_widget.composer_text_with_pending(), draft); assert!( !std::iter::from_fn(|| app_event_rx.try_recv().ok()) .any(|event| matches!(event, AppEvent::CodexOp(Op::UserTurn { .. }))) ); Ok(()) })) } #[test] fn attach_live_thread_for_selection_rejects_empty_non_ephemeral_fallback_threads() -> Result<()> { const WORKER_THREADS: usize = 1; const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024; let runtime = tokio::runtime::Builder::new_multi_thread() .worker_threads(WORKER_THREADS) .thread_stack_size(TEST_STACK_SIZE_BYTES) .enable_all() .build()?; runtime.block_on(async { let config_owner = make_test_app().await; let config = config_owner.chat_widget.config_ref().clone(); let mut app_server = crate::start_embedded_app_server_for_picker(&config) .await .expect("embedded app server"); let started = app_server.start_thread(&config).await?; let thread_id = started.session.thread_id; let mut app = make_test_app().await; app.agent_navigation.upsert( thread_id, Some("Scout".to_string()), Some("worker".to_string()), /*is_closed*/ false, ); let err = app .attach_live_thread_for_selection(&mut app_server, thread_id) .await .expect_err("empty fallback should not attach as a blank replay-only thread"); assert_eq!( err.to_string(), format!("Agent thread {thread_id} is not yet available for replay or live attach.") ); assert!(!app.thread_event_channels.contains_key(&thread_id)); Ok(()) }) } #[test] fn attach_live_thread_for_selection_rejects_unmaterialized_fallback_threads() -> Result<()> { const WORKER_THREADS: usize = 1; const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024; let runtime = tokio::runtime::Builder::new_multi_thread() .worker_threads(WORKER_THREADS) .thread_stack_size(TEST_STACK_SIZE_BYTES) .enable_all() .build()?; runtime.block_on(async { 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 ephemeral_config = app.chat_widget.config_ref().clone(); ephemeral_config.ephemeral = true; let started = app_server.start_thread(&ephemeral_config).await?; let thread_id = started.session.thread_id; app.agent_navigation.upsert( thread_id, Some("Scout".to_string()), Some("worker".to_string()), /*is_closed*/ false, ); let err = app .attach_live_thread_for_selection(&mut app_server, thread_id) .await .expect_err("ephemeral fallback should not attach as a blank live thread"); assert_eq!( err.to_string(), format!("Agent thread {thread_id} is not yet available for replay or live attach.") ); assert!(!app.thread_event_channels.contains_key(&thread_id)); Ok(()) }) } #[tokio::test] async fn refresh_agent_picker_thread_liveness_prunes_closed_metadata_only_threads() -> Result<()> { let mut app = Box::pin(make_test_app()).await; let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( app.chat_widget.config_ref(), )) .await .expect("embedded app server"); let thread_id = ThreadId::new(); app.agent_navigation.upsert( thread_id, Some("Ghost".to_string()), Some("worker".to_string()), /*is_closed*/ false, ); let is_available = Box::pin(app.refresh_agent_picker_thread_liveness(&mut app_server, thread_id)).await; assert!(!is_available); assert_eq!(app.agent_navigation.get(&thread_id), None); assert!(!app.thread_event_channels.contains_key(&thread_id)); Ok(()) } #[tokio::test] async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<()> { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; let config = app.chat_widget.config_ref().clone(); let parent_thread_id = ThreadId::from_string( &app_test_support::create_fake_rollout( config.codex_home.as_path(), "2025-01-05T12-00-00", "2025-01-05T12:00:00Z", "Saved user message", Some(config.model_provider_id.as_str()), /*git_info*/ None, ) .expect("create source rollout"), )?; let other_thread_id = ThreadId::from_string( &app_test_support::create_fake_rollout( config.codex_home.as_path(), "2025-01-06T12-00-00", "2025-01-06T12:00:00Z", "Other task", Some(config.model_provider_id.as_str()), /*git_info*/ None, ) .expect("create other rollout"), )?; let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&config)).await?; let started = app_server .resume_thread( &crate::local_settings::LocalSettings::from(&config), config, parent_thread_id, crate::app_server_session::ResumeModelSettings::RestoreFromThread, ) .await?; app.enqueue_primary_thread_session(started.session, started.turns) .await?; while app_event_rx.try_recv().is_ok() {} let mut tui = crate::tui::test_support::make_test_tui()?; app.runtime_permission_profile_override = Some(RuntimePermissionProfileOverride::from_config( app.chat_widget.config_ref(), )); app.select_permission_profile( &mut app_server, PermissionProfileSelection { profile_id: ":read-only".into(), approval_policy: Some(AskForApproval::OnRequest), approvals_reviewer: Some(ApprovalsReviewer::User), display_label: "Read Only".into(), }, ) .await; let control = Box::pin(app.handle_start_side( &mut tui, &mut app_server, parent_thread_id, /*user_message*/ None, )) .await?; let side_thread_id = app .active_thread_id .expect("side conversation should become active"); assert!(matches!(control, AppRunControl::Continue)); assert_ne!(side_thread_id, parent_thread_id); assert!(app.side_threads.contains_key(&side_thread_id)); assert!(app.thread_event_channels.contains_key(&side_thread_id)); assert!( !app.agent_navigation .get(&side_thread_id) .expect("side start should seed navigation before thread/started") .is_closed ); let mut saw_thread_started = false; for _ in 0..20 { let event = time::timeout( std::time::Duration::from_secs(/*secs*/ 2), app_server.next_event(), ) .await .expect("app-server should emit an event") .expect("app-server event stream should remain open"); if let codex_app_server_client::AppServerEvent::ServerNotification(notification) = event.event && let ServerNotification::ThreadStarted(notification) = notification.as_ref() && notification.thread.id == side_thread_id.to_string() { saw_thread_started = true; break; } } assert!(saw_thread_started); assert_eq!( app.config.permissions.permission_profile(), &PermissionProfile::read_only() ); assert_eq!( app.runtime_permission_profile_override, Some(RuntimePermissionProfileOverride::from_config( app.chat_widget.config_ref() )) ); app.select_agents_overview_thread(&mut tui, &mut app_server, other_thread_id) .await?; app.select_agents_overview_thread(&mut tui, &mut app_server, parent_thread_id) .await?; Box::pin(app.handle_start_side( &mut tui, &mut app_server, parent_thread_id, /*user_message*/ None, )) .await?; assert_eq!(app.active_thread_id, Some(side_thread_id)); assert_eq!(app.side_threads.len(), 1); app.select_agent_thread(&mut tui, &mut app_server, parent_thread_id) .await?; app.select_permission_profile( &mut app_server, PermissionProfileSelection { profile_id: ":workspace".into(), approval_policy: Some(AskForApproval::OnRequest), approvals_reviewer: Some(ApprovalsReviewer::User), display_label: "Workspace".into(), }, ) .await; app.select_agent_thread(&mut tui, &mut app_server, side_thread_id) .await?; let settings = next_thread_settings_updated(&mut app_server, parent_thread_id).await; app.enqueue_thread_notification( parent_thread_id, ServerNotification::ThreadSettingsUpdated(settings), ) .await?; app.select_agent_thread(&mut tui, &mut app_server, parent_thread_id) .await?; assert_eq!( app.config .permissions .active_permission_profile() .unwrap() .id, ":workspace" ); assert_eq!( app.runtime_permission_profile_override, Some(RuntimePermissionProfileOverride::from_config( app.chat_widget.config_ref() )) ); app_server.shutdown().await?; Ok(()) } #[tokio::test] async fn select_uncached_agent_thread_still_refreshes_liveness() -> Result<()> { let mut app = Box::pin(make_test_app()).await; let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( app.chat_widget.config_ref(), )) .await?; let thread_id = ThreadId::new(); app.agent_navigation.upsert( thread_id, Some("Ghost".to_string()), Some("worker".to_string()), /*is_closed*/ false, ); let mut tui = crate::tui::test_support::make_test_tui()?; Box::pin(app.select_agent_thread(&mut tui, &mut app_server, thread_id)).await?; assert_eq!(app.active_thread_id, None); assert_eq!(app.agent_navigation.get(&thread_id), None); app_server.shutdown().await?; Ok(()) } #[tokio::test] async fn open_agent_picker_prompts_when_subagents_disabled() -> Result<()> { let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await; let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( app.chat_widget.config_ref(), )) .await .expect("embedded app server"); let _ = app.config.features.disable(Feature::Collab); let primary_thread_id = ThreadId::new(); app.enqueue_primary_thread_session( test_thread_session(primary_thread_id, test_path_buf("/tmp/project")), Vec::new(), ) .await?; let archived_thread_id = ThreadId::new(); app.upsert_agent_picker_thread( archived_thread_id, Some("Archived".to_string()), Some("worker".to_string()), /*is_closed*/ false, ); app.set_agent_picker_thread_visibility(archived_thread_id, AgentPickerThreadVisibility::Hidden); Box::pin(app.open_agent_picker(&mut app_server)).await; assert_snapshot!(render_bottom_popup(&app.chat_widget, /*width*/ 80), @r###" Enable subagents? › As Subagents are disabled in this TUI session. gp › 1. Yes, enable Save on the server for new threads without changing this thread 2. Not now Keep subagents disabled enter select · esc back "###); Ok(()) } #[tokio::test] async fn update_memory_settings_persists_and_updates_widget_config() -> Result<()> { let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await; let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf().abs(); let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; Box::pin(app.update_memory_settings_with_app_server( &mut app_server, /*use_memories*/ false, /*generate_memories*/ false, )) .await; assert!(!app.config.memories.use_memories); assert!(!app.config.memories.generate_memories); assert!(!app.chat_widget.config_ref().memories.use_memories); assert!(!app.chat_widget.config_ref().memories.generate_memories); let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; let config_value = toml::from_str::(&config)?; let memories = config_value .as_table() .and_then(|table| table.get("memories")) .and_then(TomlValue::as_table) .expect("memories table should exist"); assert_eq!( memories.get("use_memories"), Some(&TomlValue::Boolean(false)) ); assert_eq!( memories.get("generate_memories"), Some(&TomlValue::Boolean(false)) ); assert!( !memories.contains_key("disable_on_external_context") && !memories.contains_key("no_memories_if_mcp_or_web_search"), "the TUI menu should not write the external-context memory setting" ); app_server.shutdown().await?; Ok(()) } #[test] fn update_memory_settings_updates_current_thread_memory_mode() -> Result<()> { const WORKER_THREADS: usize = 1; const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024; let runtime = tokio::runtime::Builder::new_multi_thread() .worker_threads(WORKER_THREADS) .thread_stack_size(TEST_STACK_SIZE_BYTES) .enable_all() .build()?; runtime.block_on(async { let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await; let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf().abs(); app.config.sqlite = codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()); // Seed the previous setting so this test exercises the thread-mode update path. app.config.memories.generate_memories = true; let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; let started = app_server.start_thread(&app.config).await?; let thread_id = started.session.thread_id; app.active_thread_id = Some(thread_id); Box::pin(app.update_memory_settings_with_app_server( &mut app_server, /*use_memories*/ true, /*generate_memories*/ false, )) .await; let state_db = codex_state::StateRuntime::init( codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()), app.config.model_provider_id.clone(), ) .await .expect("state db should initialize"); let memory_mode = state_db .get_thread_memory_mode(thread_id) .await .expect("thread memory mode should be readable"); assert_eq!(memory_mode.as_deref(), Some("disabled")); app_server.shutdown().await?; Ok(()) }) } #[tokio::test] async fn reset_memories_clears_local_memory_directories() -> Result<()> { Box::pin(async { let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await; let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf().abs(); app.config.sqlite = codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()); let memory_root = codex_home.path().join("memories"); let extensions_root = memory_root.join("extensions"); std::fs::create_dir_all(memory_root.join("rollout_summaries"))?; std::fs::create_dir_all(&extensions_root)?; std::fs::write(memory_root.join("MEMORY.md"), "stale memory\n")?; std::fs::write( memory_root.join("rollout_summaries").join("stale.md"), "stale summary\n", )?; std::fs::write(extensions_root.join("stale.txt"), "stale extension\n")?; let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; Box::pin(app.reset_memories_with_app_server(&mut app_server)).await; assert_eq!(std::fs::read_dir(&memory_root)?.count(), 0); app_server.shutdown().await?; Ok(()) }) .await } #[tokio::test] async fn server_only_profile_selection_keeps_turns_on_the_selected_profile() -> Result<()> { let (mut app, mut events, mut ops) = make_test_app_with_channels().await; app.config.ephemeral = false; let home = tempdir()?; std::fs::write( home.path().join("config.toml"), "default_permissions = \":workspace\"\n[permissions.server-only]\nextends = \":read-only\"\n", )?; let server_config = ConfigBuilder::default() .codex_home(home.path().into()) .build() .await?; let mut server = crate::start_embedded_app_server_for_picker(&server_config).await?; let selection = PermissionProfileSelection { profile_id: "server-only".into(), approval_policy: None, approvals_reviewer: Some(ApprovalsReviewer::User), display_label: "server-only".into(), }; app.select_permission_profile(&mut server, selection.clone()) .await; insta::assert_snapshot!( next_history_message(&mut events), @"■ Wait for the task to connect before selecting permissions." ); let started = server.start_thread(&app.config).await?; let thread_id = started.session.thread_id; app.enqueue_primary_thread_session(started.session, started.turns) .await?; app.runtime_approvals_reviewer_override = Some(ApprovalsReviewer::AutoReview); app.runtime_permission_profile_override = Some(RuntimePermissionProfileOverride::from_config(&app.config)); while events.try_recv().is_ok() {} app.select_permission_profile(&mut server, selection.clone()) .await; insta::assert_snapshot!( next_history_message(&mut events), @"• Permission selection requested: server-only" ); let mut tui = crate::tui::test_support::make_test_tui()?; app.chat_widget .restore_user_message_to_composer("use the selected profile".into()); app.chat_widget .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); let turn = next_user_turn_op(&mut ops); app.submit_thread_op(&mut server, thread_id, turn).await?; server.startup_interrupt(thread_id).await?; let settings = next_thread_settings_updated(&mut server, thread_id).await; app.enqueue_thread_notification( thread_id, ServerNotification::ThreadSettingsUpdated(settings), ) .await?; assert!(!app.pending_server_profiles.contains_key(&thread_id)); assert_eq!( ( app.runtime_approvals_reviewer_override, app.chat_widget.config_ref().approvals_reviewer ), (None, ApprovalsReviewer::User), ); app.runtime_approvals_reviewer_override = Some(ApprovalsReviewer::User); app.select_permission_profile( &mut server, PermissionProfileSelection { approvals_reviewer: None, ..selection.clone() }, ) .await; let settings = next_thread_settings_updated(&mut server, thread_id).await; app.enqueue_thread_notification( thread_id, ServerNotification::ThreadSettingsUpdated(settings), ) .await?; assert_eq!( app.runtime_approvals_reviewer_override, Some(ApprovalsReviewer::User), ); let endpoint = crate::resolve_remote_addr("ws://127.0.0.1:8765")?; app.app_server_target = crate::AppServerTarget::Remote { endpoint }; assert!(app.selected_server_profile(thread_id).is_none()); while events.try_recv().is_ok() {} app.change_working_directory(&mut tui, &mut server, home.path().abs()) .await; insta::assert_snapshot!( next_history_message(&mut events), @"■ Changing directories with a named profile is not supported." ); app.handle_thread_event_now(ThreadBufferedEvent::Notification(Box::new( turn_completed_notification(thread_id, "first", TurnStatus::Interrupted), ))); while events.try_recv().is_ok() {} app.chat_widget .restore_user_message_to_composer("busy".into()); app.chat_widget .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); let mut turn = next_user_turn_op(&mut ops); if let Op::UserTurn { active_permission_profile, .. } = &mut turn { *active_permission_profile = Some(ActivePermissionProfile::new("stale-profile")); } app.submit_thread_op(&mut server, thread_id, turn).await?; let thread = server .thread_read(thread_id, /*include_turns*/ true) .await?; assert_eq!(thread.turns.len(), 2); while events.try_recv().is_ok() {} app.chat_widget .set_queue_autosend_suppressed(/*suppressed*/ true); app.chat_widget .restore_user_message_to_composer("queued follow-up".into()); app.chat_widget .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); let selection = PermissionProfileSelection { profile_id: "server-only".into(), approval_policy: None, approvals_reviewer: None, display_label: "server-only".into(), }; app.select_permission_profile(&mut server, selection).await; insta::assert_snapshot!( next_history_message(&mut events), @"• Permission selection requested: server-only" ); app.handle_event(&mut tui, &mut server, AppEvent::SettingsSelectionSettled) .await?; app.handle_thread_event_now(ThreadBufferedEvent::Notification(Box::new( turn_completed_notification(thread_id, "second", TurnStatus::Completed), ))); assert!(!app.chat_widget.has_queued_follow_up_messages()); let _ = next_user_turn_op(&mut ops); server.shutdown().await?; Ok(()) } #[tokio::test] async fn remote_resume_rejects_explicit_permission_override() -> Result<()> { let (mut app, mut events, _ops) = make_test_app_with_channels().await; let endpoint = crate::resolve_remote_addr("ws://127.0.0.1:8765")?; let home = tempdir()?; std::fs::write( home.path().join("config.toml"), "default_permissions = \":workspace\"\n", )?; app.config.codex_home = home.path().to_path_buf().abs(); let mut server = crate::start_embedded_app_server_for_picker(&app.config).await?; let backend_id = server.backend_id(); app.cli_kv_overrides .push(("default_permissions".into(), ":workspace".into())); assert!( !config_persistence::has_explicit_resume_permission_override( &app.config, &app.harness_overrides ) ); let refreshed = app .rebuild_config_for_cwd(app.config.cwd.to_path_buf()) .await?; assert!(config_persistence::has_explicit_resume_permission_override( &refreshed, &app.harness_overrides )); let mut tui = crate::tui::test_support::make_test_tui()?; let mut settings = app.backend_settings(); settings.target = crate::AppServerTarget::Remote { endpoint }; let result = app .prepare_target_session( &mut tui, &mut server, &SessionTarget { backend_id, thread_id: ThreadId::new(), path: None, cwd: None, history_mode: None, }, super::resume_preparation::ResumeSource::Backend(&settings), ) .await; assert!(matches!(result, Err(AppRunControl::Continue))); assert_eq!(app.active_thread_id, None); let cell = std::iter::from_fn(|| events.try_recv().ok()) .find_map(|event| match event { AppEvent::InsertHistoryCell(cell) => Some(cell), _ => None, }) .expect("permission override error"); insta::assert_snapshot!( lines_to_single_string(&cell.display_lines(/*width*/ 80)), @"■ Permission overrides are not supported when resuming a remote task." ); server.shutdown().await?; Ok(()) } #[tokio::test] async fn apply_permission_profile_selection_preserves_loader_overrides() -> Result<()> { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; let codex_home = tempdir()?; let selected_config = codex_home.path().join("work.config.toml"); std::fs::write( &selected_config, r#" default_permissions = "locked-down" [permissions.locked-down.filesystem] ":minimal" = "read" "#, )?; app.config.codex_home = codex_home.path().to_path_buf().abs(); app.loader_overrides.user_config_path = Some(selected_config.abs()); app.harness_overrides.sandbox_mode = Some(SandboxMode::WorkspaceWrite); app.harness_overrides.permission_profile = Some(PermissionProfile::workspace_write()); app.set_approvals_reviewer_in_app_and_widget(ApprovalsReviewer::AutoReview); assert!( app.apply_permission_profile_selection(PermissionProfileSelection { profile_id: "locked-down".to_string(), approval_policy: None, approvals_reviewer: None, display_label: "locked-down".to_string(), }) .await ); assert_eq!( app.config .permissions .active_permission_profile() .as_ref() .map(|profile| profile.id.as_str()), Some("locked-down") ); assert_eq!( app.chat_widget .config_ref() .permissions .active_permission_profile() .as_ref() .map(|profile| profile.id.as_str()), Some("locked-down") ); assert_eq!( app.runtime_permission_profile_override, Some(RuntimePermissionProfileOverride::from_config(&app.config)) ); let mut destination = app.config.clone(); destination.approvals_reviewer = ApprovalsReviewer::User; app.apply_runtime_policy_overrides(&mut destination, RuntimePolicyOverrideScope::ExplicitOnly)?; assert_eq!(destination.approvals_reviewer, ApprovalsReviewer::User); assert!( !app.resume_permission_overrides(&destination) .approvals_reviewer ); let op = match app_event_rx.try_recv() { Ok(AppEvent::CodexOp(op)) => op, other => panic!("expected CodexOp event, got {other:?}"), }; assert_eq!( op, Op::OverrideTurnContext { cwd: None, approval_policy: None, approvals_reviewer: None, permission_profile: Some(app.config.permissions.permission_profile().clone()), active_permission_profile: app.config.permissions.active_permission_profile(), model: None, effort: None, summary: None, service_tier: None, collaboration_mode: None, } ); let cell = match app_event_rx.try_recv() { Ok(AppEvent::InsertHistoryCell(cell)) => cell, other => panic!("expected InsertHistoryCell event, got {other:?}"), }; let rendered = cell .display_lines(/*width*/ 120) .into_iter() .map(|line| line.to_string()) .collect::>() .join("\n"); assert!(rendered.contains("Permissions updated to locked-down")); Ok(()) } #[tokio::test] async fn update_feature_flags_enabling_guardian_selects_auto_review() -> Result<()> { let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf().abs(); let auto_review = auto_review_mode(); let mut app_server = start_config_write_test_app_server(&app).await?; app.update_feature_flags(&mut app_server, vec![(Feature::GuardianApproval, true)]) .await; assert!(app.config.features.enabled(Feature::GuardianApproval)); assert!( app.chat_widget .config_ref() .features .enabled(Feature::GuardianApproval) ); assert_eq!( app.config.approvals_reviewer, auto_review.approvals_reviewer ); assert_eq!( AskForApproval::from(app.config.permissions.approval_policy.value()), auto_review.approval_policy ); assert_eq!( AskForApproval::from( app.chat_widget .config_ref() .permissions .approval_policy .value(), ), auto_review.approval_policy ); assert_eq!( app.chat_widget .config_ref() .permissions .permission_profile(), &auto_review.permission_profile() ); assert_eq!( app.config.permissions.active_permission_profile(), Some(auto_review.active_permission_profile.clone()) ); assert_eq!( app.chat_widget .config_ref() .permissions .active_permission_profile(), Some(auto_review.active_permission_profile.clone()) ); assert_eq!( app.chat_widget.config_ref().approvals_reviewer, auto_review.approvals_reviewer ); assert_eq!(app.runtime_approval_policy_override, None); assert_eq!( app.runtime_permission_profile_override, Some(RuntimePermissionProfileOverride::from_config(&app.config)) ); assert_eq!( op_rx.try_recv(), Ok(Op::OverrideTurnContext { cwd: None, approval_policy: Some(auto_review.approval_policy), approvals_reviewer: Some(auto_review.approvals_reviewer), permission_profile: Some(auto_review.permission_profile()), active_permission_profile: Some(auto_review.active_permission_profile.clone()), model: None, effort: None, summary: None, service_tier: None, collaboration_mode: None, }) ); let cell = match app_event_rx.try_recv() { Ok(AppEvent::InsertHistoryCell(cell)) => cell, other => panic!("expected InsertHistoryCell event, got {other:?}"), }; let rendered = cell .display_lines(/*width*/ 120) .into_iter() .map(|line| line.to_string()) .collect::>() .join("\n"); assert!(rendered.contains("Permissions updated to Approve for me")); let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; assert!(config.contains("guardian_approval = true")); assert!(config.contains("approvals_reviewer = \"auto_review\"")); assert!(config.contains("approval_policy = \"on-request\"")); assert!(config.contains("sandbox_mode = \"workspace-write\"")); app_server.shutdown().await?; Ok(()) } #[tokio::test] async fn update_feature_flags_disabling_guardian_clears_review_policy_and_restores_default() -> Result<()> { let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf().abs(); let config_toml_path = codex_home.path().join("config.toml").abs(); let config_toml = "approvals_reviewer = \"guardian_subagent\"\napproval_policy = \"on-request\"\nsandbox_mode = \"workspace-write\"\n\n[features]\nguardian_approval = true\n"; std::fs::write(config_toml_path.as_path(), config_toml)?; let user_config = toml::from_str::(config_toml)?; app.config.config_layer_stack = app .config .config_layer_stack .with_user_config(&config_toml_path, user_config)?; app.config .features .set_enabled(Feature::GuardianApproval, /*enabled*/ true)?; app.chat_widget .set_feature_enabled(Feature::GuardianApproval, /*enabled*/ true); app.config.approvals_reviewer = ApprovalsReviewer::AutoReview; app.chat_widget .set_approvals_reviewer(ApprovalsReviewer::AutoReview); app.config .permissions .approval_policy .set(AskForApproval::OnRequest.to_core())?; app.config .permissions .set_permission_profile(PermissionProfile::workspace_write())?; app.chat_widget .set_approval_policy(AskForApproval::OnRequest); app.chat_widget .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::legacy( PermissionProfile::workspace_write(), ))?; let mut app_server = start_config_write_test_app_server(&app).await?; app.update_feature_flags(&mut app_server, vec![(Feature::GuardianApproval, false)]) .await; assert!(!app.config.features.enabled(Feature::GuardianApproval)); assert!( !app.chat_widget .config_ref() .features .enabled(Feature::GuardianApproval) ); assert_eq!(app.config.approvals_reviewer, ApprovalsReviewer::User); assert_eq!( AskForApproval::from(app.config.permissions.approval_policy.value()), AskForApproval::OnRequest ); assert_eq!( app.chat_widget.config_ref().approvals_reviewer, ApprovalsReviewer::User ); assert_eq!(app.runtime_approval_policy_override, None); assert_eq!( op_rx.try_recv(), Ok(Op::OverrideTurnContext { cwd: None, approval_policy: None, approvals_reviewer: Some(ApprovalsReviewer::User), permission_profile: None, active_permission_profile: None, model: None, effort: None, summary: None, service_tier: None, collaboration_mode: None, }) ); let cell = match app_event_rx.try_recv() { Ok(AppEvent::InsertHistoryCell(cell)) => cell, other => panic!("expected InsertHistoryCell event, got {other:?}"), }; let rendered = cell .display_lines(/*width*/ 120) .into_iter() .map(|line| line.to_string()) .collect::>() .join("\n"); assert!(rendered.contains("Permissions updated to Ask for approval")); let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; assert!(!config.contains("guardian_approval = true")); assert!(!config.contains("approvals_reviewer =")); assert!(config.contains("approval_policy = \"on-request\"")); assert!(config.contains("sandbox_mode = \"workspace-write\"")); app_server.shutdown().await?; Ok(()) } #[tokio::test] async fn update_feature_flags_enabling_guardian_overrides_explicit_manual_review_policy() -> Result<()> { let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf().abs(); let auto_review = auto_review_mode(); let config_toml_path = codex_home.path().join("config.toml").abs(); let config_toml = "approvals_reviewer = \"user\"\n"; std::fs::write(config_toml_path.as_path(), config_toml)?; let user_config = toml::from_str::(config_toml)?; app.config.config_layer_stack = app .config .config_layer_stack .with_user_config(&config_toml_path, user_config)?; app.config.approvals_reviewer = ApprovalsReviewer::User; app.chat_widget .set_approvals_reviewer(ApprovalsReviewer::User); let mut app_server = start_config_write_test_app_server(&app).await?; app.update_feature_flags(&mut app_server, vec![(Feature::GuardianApproval, true)]) .await; assert!(app.config.features.enabled(Feature::GuardianApproval)); assert_eq!( app.config.approvals_reviewer, auto_review.approvals_reviewer ); assert_eq!( app.chat_widget.config_ref().approvals_reviewer, auto_review.approvals_reviewer ); assert_eq!( AskForApproval::from(app.config.permissions.approval_policy.value()), auto_review.approval_policy ); assert_eq!( app.chat_widget .config_ref() .permissions .permission_profile(), &auto_review.permission_profile() ); assert_eq!( op_rx.try_recv(), Ok(Op::OverrideTurnContext { cwd: None, approval_policy: Some(auto_review.approval_policy), approvals_reviewer: Some(auto_review.approvals_reviewer), permission_profile: Some(auto_review.permission_profile()), active_permission_profile: Some(auto_review.active_permission_profile.clone()), model: None, effort: None, summary: None, service_tier: None, collaboration_mode: None, }) ); let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; assert!(config.contains("approvals_reviewer = \"auto_review\"")); assert!(config.contains("guardian_approval = true")); assert!(config.contains("approval_policy = \"on-request\"")); assert!(config.contains("sandbox_mode = \"workspace-write\"")); app_server.shutdown().await?; Ok(()) } #[tokio::test] async fn update_feature_flags_disabling_guardian_clears_manual_review_policy_without_history() -> Result<()> { let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf().abs(); let config_toml_path = codex_home.path().join("config.toml").abs(); let config_toml = "approvals_reviewer = \"user\"\napproval_policy = \"on-request\"\nsandbox_mode = \"workspace-write\"\n\n[features]\nguardian_approval = true\n"; std::fs::write(config_toml_path.as_path(), config_toml)?; let user_config = toml::from_str::(config_toml)?; app.config.config_layer_stack = app .config .config_layer_stack .with_user_config(&config_toml_path, user_config)?; app.config .features .set_enabled(Feature::GuardianApproval, /*enabled*/ true)?; app.chat_widget .set_feature_enabled(Feature::GuardianApproval, /*enabled*/ true); app.config.approvals_reviewer = ApprovalsReviewer::User; app.chat_widget .set_approvals_reviewer(ApprovalsReviewer::User); let mut app_server = start_config_write_test_app_server(&app).await?; app.update_feature_flags(&mut app_server, vec![(Feature::GuardianApproval, false)]) .await; assert!(!app.config.features.enabled(Feature::GuardianApproval)); assert_eq!(app.config.approvals_reviewer, ApprovalsReviewer::User); assert_eq!( app.chat_widget.config_ref().approvals_reviewer, ApprovalsReviewer::User ); assert_eq!( op_rx.try_recv(), Ok(Op::OverrideTurnContext { cwd: None, approval_policy: None, approvals_reviewer: Some(ApprovalsReviewer::User), permission_profile: None, active_permission_profile: None, model: None, effort: None, summary: None, service_tier: None, collaboration_mode: None, }) ); assert!( app_event_rx.try_recv().is_err(), "manual review should not emit a permissions history update when the effective state stays default" ); let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; assert!(!config.contains("guardian_approval = true")); assert!(!config.contains("approvals_reviewer =")); app_server.shutdown().await?; Ok(()) } #[tokio::test] async fn open_agent_picker_allows_existing_agent_threads_when_feature_is_disabled() -> Result<()> { let (mut app, mut app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await; let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( app.chat_widget.config_ref(), )) .await .expect("embedded app server"); let thread_id = ThreadId::new(); app.thread_event_channels .insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1)); Box::pin(app.open_agent_picker(&mut app_server)).await; app.chat_widget .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_matches!( app_event_rx.try_recv(), Ok(AppEvent::SelectAgentThread(selected_thread_id)) if selected_thread_id == thread_id ); Ok(()) } #[tokio::test] async fn refresh_pending_thread_approvals_only_lists_inactive_threads() { let mut app = make_test_app().await; let main_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000001").expect("valid thread"); let agent_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000002").expect("valid thread"); app.primary_thread_id = Some(main_thread_id); app.active_thread_id = Some(main_thread_id); app.thread_event_channels .insert(main_thread_id, ThreadEventChannel::new(/*capacity*/ 1)); let agent_channel = ThreadEventChannel::new(/*capacity*/ 1); { let mut store = agent_channel.store.lock().await; store.push_request(exec_approval_request( agent_thread_id, "turn-1", "call-1", /*approval_id*/ None, )); } app.thread_event_channels .insert(agent_thread_id, agent_channel); app.agent_navigation.upsert( agent_thread_id, Some("Robie".to_string()), Some("explorer".to_string()), /*is_closed*/ false, ); app.refresh_pending_thread_approvals().await; assert_eq!( app.chat_widget.pending_thread_approvals(), &["Robie [explorer]".to_string()] ); app.active_thread_id = Some(agent_thread_id); app.refresh_pending_thread_approvals().await; assert!(app.chat_widget.pending_thread_approvals().is_empty()); } #[tokio::test] async fn inactive_thread_approval_bubbles_into_active_view() -> Result<()> { let mut app = make_test_app().await; let main_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000011").expect("valid thread"); let agent_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000022").expect("valid thread"); app.primary_thread_id = Some(main_thread_id); app.active_thread_id = Some(main_thread_id); app.thread_event_channels .insert(main_thread_id, ThreadEventChannel::new(/*capacity*/ 1)); app.thread_event_channels.insert( agent_thread_id, ThreadEventChannel::new_with_session( /*capacity*/ 1, ThreadSessionState { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::workspace_write(), rollout_path: Some(test_path_buf("/tmp/agent-rollout.jsonl")), ..test_thread_session(agent_thread_id, test_path_buf("/tmp/agent")) }, Vec::new(), ), ); app.agent_navigation.upsert( agent_thread_id, Some("Robie".to_string()), Some("explorer".to_string()), /*is_closed*/ false, ); app.enqueue_thread_request( agent_thread_id, exec_approval_request( agent_thread_id, "turn-approval", "call-approval", /*approval_id*/ None, ), ) .await?; assert_eq!(app.chat_widget.has_active_view(), true); assert_eq!( app.chat_widget.pending_thread_approvals(), &["Robie [explorer]".to_string()] ); Ok(()) } #[tokio::test] async fn side_defers_parent_approval_overlay_until_parent_replay() -> Result<()> { let mut app = make_test_app().await; let parent_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000011").expect("valid thread"); let side_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000022").expect("valid thread"); 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.thread_event_channels.insert( parent_thread_id, ThreadEventChannel::new_with_session( /*capacity*/ 4, test_thread_session(parent_thread_id, test_path_buf("/tmp/main")), Vec::new(), ), ); app.enqueue_thread_request( parent_thread_id, exec_approval_request( parent_thread_id, "turn-approval", "call-approval", /*approval_id*/ None, ), ) .await?; assert_eq!(app.chat_widget.has_active_view(), false); assert!(app.chat_widget.pending_thread_approvals().is_empty()); assert_eq!( app.side_threads .get(&side_thread_id) .and_then(|state| state.parent_status), Some(SideParentStatus::NeedsApproval) ); let snapshot = { let channel = app .thread_event_channels .get(&parent_thread_id) .expect("parent thread channel"); let store = channel.store.lock().await; store.snapshot() }; app.side_threads.remove(&side_thread_id); app.active_thread_id = Some(parent_thread_id); app.replay_thread_snapshot(snapshot, /*resume_restored_queue*/ false); assert_eq!(app.chat_widget.has_active_view(), true); Ok(()) } #[tokio::test] async fn replay_snapshot_with_pending_request_retains_warnings_without_covering_approval() { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000011").expect("valid thread"); let stale_warning = "stale startup warning that should not cover the approval"; app.replay_thread_snapshot( ThreadEventSnapshot { delegated_turns: Vec::new(), session: Some(test_thread_session(thread_id, test_path_buf("/tmp/main"))), turns: Vec::new(), events: vec![ ThreadBufferedEvent::Notification(Box::new(ServerNotification::Warning( WarningNotification { thread_id: Some(thread_id.to_string()), message: stale_warning.to_string(), }, ))), ThreadBufferedEvent::Request(Box::new(exec_approval_request( thread_id, "turn-approval", "call-approval", /*approval_id*/ None, ))), ], active_reasoning_item: None, input_state: None, }, /*resume_restored_queue*/ false, ); assert_eq!(app.chat_widget.has_active_view(), true); let mut replayed_history = String::new(); let mut warnings = Vec::new(); while let Ok(event) = app_event_rx.try_recv() { if let AppEvent::InsertHistoryCell(cell) = event { warnings.extend( cell.warning_entries() .into_iter() .map(|entry| entry.details), ); replayed_history.push_str(&lines_to_single_string(&cell.display_lines(/*width*/ 80))); } } assert_eq!(warnings, vec![stale_warning]); assert!( replayed_history.is_empty(), "expected pending approval replay to suppress session notices, got {replayed_history:?}" ); } #[tokio::test] async fn side_defers_subagent_approval_overlay_until_side_exits() -> Result<()> { let mut app = make_test_app().await; let main_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000011").expect("valid thread"); let side_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000022").expect("valid thread"); let agent_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000033").expect("valid thread"); let quiet_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000044").expect("valid thread"); app.primary_thread_id = Some(main_thread_id); app.active_thread_id = Some(side_thread_id); app.side_threads .insert(side_thread_id, SideThreadState::new(main_thread_id)); app.thread_event_channels.insert( agent_thread_id, ThreadEventChannel::new_with_session( /*capacity*/ 4, ThreadSessionState { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::workspace_write(), rollout_path: Some(test_path_buf("/tmp/agent-rollout.jsonl")), ..test_thread_session(agent_thread_id, test_path_buf("/tmp/agent")) }, Vec::new(), ), ); app.agent_navigation.upsert( agent_thread_id, Some("Robie".to_string()), Some("explorer".to_string()), /*is_closed*/ false, ); let pending_approval = exec_approval_request( agent_thread_id, "turn-approval", "call-approval", /*approval_id*/ None, ); app.enqueue_thread_request(agent_thread_id, pending_approval.clone()) .await?; app.enqueue_thread_request( quiet_thread_id, ServerRequest::DynamicToolCall { request_id: AppServerRequestId::Integer(99), params: codex_app_server_protocol::DynamicToolCallParams { thread_id: quiet_thread_id.to_string(), turn_id: "turn-quiet".to_string(), call_id: "call-quiet".to_string(), namespace: None, tool: "ignored-tool".to_string(), arguments: serde_json::json!({}), }, }, ) .await?; assert_eq!(app.chat_widget.has_active_view(), false); assert_eq!( app.chat_widget.pending_thread_approvals(), &["Robie [explorer]".to_string()] ); app.side_threads.remove(&side_thread_id); app.active_thread_id = Some(main_thread_id); assert_eq!( app.pending_inactive_thread_requests().await, vec![(agent_thread_id, pending_approval)] ); app.surface_pending_inactive_thread_interactive_requests() .await?; assert_eq!(app.chat_widget.has_active_view(), true); Ok(()) } #[tokio::test] async fn inactive_thread_exec_approval_preserves_context() { let app = make_test_app().await; let thread_id = ThreadId::new(); let mut request = exec_approval_request( thread_id, "turn-approval", "call-approval", /*approval_id*/ None, ); let ServerRequest::CommandExecutionRequestApproval { params, .. } = &mut request else { panic!("expected exec approval request"); }; params.network_approval_context = Some(AppServerNetworkApprovalContext { host: "example.com".to_string(), protocol: AppServerNetworkApprovalProtocol::Socks5Tcp, }); params.additional_permissions = Some(AdditionalPermissionProfile { network: Some(AdditionalNetworkPermissions { enabled: Some(true), }), file_system: Some(AdditionalFileSystemPermissions { read: Some(vec![test_absolute_path("/tmp/read-only").into()]), write: Some(vec![test_absolute_path("/tmp/write").into()]), glob_scan_max_depth: None, entries: None, }), }); params.proposed_network_policy_amendments = Some(vec![AppServerNetworkPolicyAmendment { host: "example.com".to_string(), action: AppServerNetworkPolicyRuleAction::Allow, }]); let Some(ThreadInteractiveRequest::Approval(ApprovalRequest::Exec(approval))) = app .interactive_request_for_thread_request(thread_id, &request) .await .expect("valid localized paths") else { panic!("expected exec approval request"); }; assert_eq!( approval.network_approval_context, Some(AppServerNetworkApprovalContext { host: "example.com".to_string(), protocol: AppServerNetworkApprovalProtocol::Socks5Tcp, }) ); assert_eq!( approval.additional_permissions, Some(AdditionalPermissionProfile { network: Some(AdditionalNetworkPermissions { enabled: Some(true), }), file_system: Some(AdditionalFileSystemPermissions { read: Some(vec![test_absolute_path("/tmp/read-only").into()]), write: Some(vec![test_absolute_path("/tmp/write").into()]), glob_scan_max_depth: None, entries: None, }), }) ); assert_eq!( approval.available_decisions, vec![ codex_app_server_protocol::CommandExecutionApprovalDecision::Accept, codex_app_server_protocol::CommandExecutionApprovalDecision::AcceptForSession, codex_app_server_protocol::CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment { network_policy_amendment: AppServerNetworkPolicyAmendment { host: "example.com".to_string(), action: AppServerNetworkPolicyRuleAction::Allow, }, }, codex_app_server_protocol::CommandExecutionApprovalDecision::Cancel, ] ); } #[tokio::test] async fn inactive_thread_exec_approval_splits_shell_wrapped_command() { let app = make_test_app().await; let thread_id = ThreadId::new(); let script = r#"python3 -c 'print("Hello, world!")'"#; let mut request = exec_approval_request( thread_id, "turn-approval", "call-approval", /*approval_id*/ None, ); let ServerRequest::CommandExecutionRequestApproval { params, .. } = &mut request else { panic!("expected exec approval request"); }; params.command = Some(shlex::try_join(["/bin/zsh", "-lc", script]).expect("round-trippable shell wrapper")); let Some(ThreadInteractiveRequest::Approval(ApprovalRequest::Exec(approval))) = app .interactive_request_for_thread_request(thread_id, &request) .await .expect("valid localized paths") else { panic!("expected exec approval request"); }; assert_eq!( approval.command, vec![ "/bin/zsh".to_string(), "-lc".to_string(), script.to_string(), ] ); } #[tokio::test] async fn inactive_thread_file_change_approval_recovers_buffered_changes() { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::new(); app.enqueue_thread_notification( thread_id, ServerNotification::ItemStarted(ItemStartedNotification::new( thread_id.to_string(), "turn-approval".to_string(), ThreadItemPayload::FileChange { id: "patch-approval".to_string(), changes: vec![FileUpdateChange { path: "README.md".to_string(), kind: PatchChangeKind::Add, diff: "hello\n".to_string(), }], status: codex_app_server_protocol::PatchApplyStatus::InProgress, }, /*started_at_ms*/ 0, )), ) .await .expect("enqueue file change item"); let request = ServerRequest::FileChangeRequestApproval { request_id: AppServerRequestId::Integer(9), params: FileChangeRequestApprovalParams { thread_id: thread_id.to_string(), turn_id: "turn-approval".to_string(), item_id: "patch-approval".to_string(), started_at_ms: 0, reason: Some("command failed; retry without sandbox?".to_string()), grant_root: None, }, }; let request = app .interactive_request_for_thread_request(thread_id, &request) .await .expect("valid localized paths") .expect("expected file change approval request"); let ThreadInteractiveRequest::Approval(ApprovalRequest::ApplyPatch(approval)) = &request else { panic!("expected apply-patch approval request"); }; assert_eq!( &approval.changes, &HashMap::from([( PathBuf::from("README.md"), FileChange::Add { content: "hello\n".to_string(), }, )]) ); assert_eq!( &approval.reason, &Some("command failed; retry without sandbox?".to_string()) ); app.push_thread_interactive_request(request); let cell = match app_event_rx.try_recv() { Ok(AppEvent::InsertHistoryCell(cell)) => cell, other => panic!("expected patch preview history cell, saw {other:?}"), }; let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80)); assert!(rendered.contains("• Added README.md (+1 -0)")); assert!(rendered.contains("1 +hello")); } #[tokio::test] async fn active_thread_file_change_approval_recovers_buffered_changes() { let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; let app_server = crate::start_embedded_app_server_for_picker(&app.config) .await .expect("embedded app server"); let thread_id = ThreadId::new(); app.active_thread_id = Some(thread_id); app.primary_thread_id = Some(ThreadId::new()); app.agents_overview .dispatched_requests .insert(thread_id, Vec::new()); app.startup_protected_input_boundary = true; app.enqueue_thread_notification( thread_id, ServerNotification::ItemStarted(ItemStartedNotification::new( thread_id.to_string(), "turn-active-approval".to_string(), ThreadItemPayload::FileChange { id: "patch-active-approval".to_string(), changes: vec![FileUpdateChange { path: "visible-target.md".to_string(), kind: PatchChangeKind::Add, diff: "hello\n".to_string(), }], status: codex_app_server_protocol::PatchApplyStatus::InProgress, }, /*started_at_ms*/ 0, )), ) .await .expect("enqueue file change item"); let request = ServerRequest::FileChangeRequestApproval { request_id: AppServerRequestId::Integer(10), params: FileChangeRequestApprovalParams { thread_id: thread_id.to_string(), turn_id: "turn-active-approval".to_string(), item_id: "patch-active-approval".to_string(), started_at_ms: 0, reason: None, grant_root: None, }, }; app.handle_app_server_event( &app_server, codex_app_server_client::AppServerEvent::ServerRequest(Box::new(request.clone())), ) .await; assert!(app.agents_overview.dispatched_requests[&thread_id].is_empty()); assert!( app.pending_app_server_requests .contains_server_request(&request) ); app.chat_widget.handle_server_notification( agent_message_delta_notification(thread_id, "turn-active-approval", "agent-1", "streaming"), /*replay_kind*/ None, ); let event = ThreadBufferedEvent::Request(Box::new(request)); app.handle_thread_event_now_recovering_file_changes(event) .await; assert!(!app.chat_widget.has_active_view()); assert!(app.startup_pending_protected_request); app.chat_widget.handle_server_notification( ServerNotification::ItemCompleted( codex_app_server_protocol::ItemCompletedNotification::new( thread_id.to_string(), "turn-active-approval".to_string(), ThreadItemPayload::AgentMessage { id: "agent-1".to_string(), text: "streaming".to_string(), phase: None, memory_citation: None, delivery: None, questions: None, }, /*started_at_ms*/ None, /*completed_at_ms*/ 0, ), ), /*replay_kind*/ None, ); let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 100); let destination = app.chat_widget.config_ref().cwd.join("visible-target.md"); assert!(rendered.contains("Description: Apply proposed file edits")); assert!(rendered.contains(&format!("Destination: {}", destination.display()))); app_server.shutdown().await.expect("shutdown app server"); } #[tokio::test] async fn replayed_file_change_approval_recovers_snapshot_changes() { let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::new(); let cwd = test_path_buf("/tmp/project").abs(); app.replay_thread_snapshot( ThreadEventSnapshot { delegated_turns: Vec::new(), session: Some(test_thread_session(thread_id, cwd.clone().into_path_buf())), turns: vec![test_turn( "turn-replayed-approval", TurnStatus::InProgress, vec![ThreadItemPayload::FileChange { id: "patch-replayed-approval".to_string(), changes: vec![FileUpdateChange { path: "visible-target.md".to_string(), kind: PatchChangeKind::Add, diff: "hello\n".to_string(), }], status: codex_app_server_protocol::PatchApplyStatus::InProgress, }], )], events: vec![ThreadBufferedEvent::Request(Box::new( ServerRequest::FileChangeRequestApproval { request_id: AppServerRequestId::Integer(11), params: FileChangeRequestApprovalParams { thread_id: thread_id.to_string(), turn_id: "turn-replayed-approval".to_string(), item_id: "patch-replayed-approval".to_string(), started_at_ms: 0, reason: None, grant_root: None, }, }, ))], active_reasoning_item: None, input_state: None, }, /*resume_restored_queue*/ false, ); let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 100).replace( &cwd.join("visible-target.md").display().to_string(), "/tmp/project/visible-target.md", ); assert_app_snapshot!("replayed_file_change_approval", rendered); } #[tokio::test] async fn inactive_thread_permissions_approval_preserves_file_system_permissions() { let app = make_test_app().await; let thread_id = ThreadId::new(); let request = ServerRequest::PermissionsRequestApproval { request_id: AppServerRequestId::Integer(7), params: PermissionsRequestApprovalParams { thread_id: thread_id.to_string(), turn_id: "turn-approval".to_string(), item_id: "call-approval".to_string(), environment_id: Some("remote".to_string()), started_at_ms: 0, cwd: test_absolute_path("/tmp").into(), reason: Some("Need access to .git".to_string()), permissions: codex_app_server_protocol::RequestPermissionProfile { network: Some(AdditionalNetworkPermissions { enabled: Some(true), }), file_system: Some(AdditionalFileSystemPermissions { read: Some(vec![test_absolute_path("/tmp/read-only").into()]), write: Some(vec![test_absolute_path("/tmp/write").into()]), glob_scan_max_depth: None, entries: None, }), }, }, }; let Some(ThreadInteractiveRequest::Approval(ApprovalRequest::Permissions(approval))) = app .interactive_request_for_thread_request(thread_id, &request) .await .expect("valid localized paths") else { panic!("expected permissions approval request"); }; assert_eq!(approval.environment_id.as_deref(), Some("remote")); assert_eq!( approval.permissions, RequestPermissionProfile { network: Some(NetworkPermissions { enabled: Some(true), }), file_system: Some(FileSystemPermissions::from_read_write_roots( Some(vec![test_absolute_path("/tmp/read-only")]), Some(vec![test_absolute_path("/tmp/write")]), )), } ); } #[tokio::test] async fn inactive_thread_url_elicitation_routes_to_app_link() { let app = make_test_app().await; let thread_id = ThreadId::new(); let request = ServerRequest::McpServerElicitationRequest { request_id: AppServerRequestId::Integer(9), params: McpServerElicitationRequestParams { thread_id: thread_id.to_string(), turn_id: Some("turn-auth".to_string()), server_name: "payments".to_string(), request: McpServerElicitationRequest::Url { meta: None, message: "Review the payment details to continue.".to_string(), url: "https://payments.example/checkout/123".to_string(), elicitation_id: "payment-123".to_string(), }, }, }; let Some(ThreadInteractiveRequest::AppLink(params)) = app .interactive_request_for_thread_request(thread_id, &request) .await .expect("valid localized paths") else { panic!("expected app link request"); }; assert_eq!(params.title, "Action required"); assert_eq!(params.description, Some("Server: payments".to_string())); assert_eq!(params.url, "https://payments.example/checkout/123"); assert_eq!( params.elicitation_target, Some(crate::bottom_pane::AppLinkElicitationTarget { thread_id, server_name: "payments".to_string(), request_id: AppServerRequestId::Integer(9), }) ); } #[tokio::test] async fn inactive_thread_invalid_url_elicitation_is_declined() { let (app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::new(); let request = ServerRequest::McpServerElicitationRequest { request_id: AppServerRequestId::Integer(10), params: McpServerElicitationRequestParams { thread_id: thread_id.to_string(), turn_id: Some("turn-auth".to_string()), server_name: "payments".to_string(), request: McpServerElicitationRequest::Url { meta: None, message: "Review the payment details to continue.".to_string(), url: "http://payments.example/checkout/123".to_string(), elicitation_id: "payment-123".to_string(), }, }, }; assert!( app.interactive_request_for_thread_request(thread_id, &request) .await .expect("valid localized paths") .is_none() ); assert_matches!( app_event_rx.try_recv(), Ok(AppEvent::SubmitThreadOp { thread_id: op_thread_id, op: Op::ResolveElicitation { server_name, request_id: AppServerRequestId::Integer(10), decision: codex_app_server_protocol::McpServerElicitationAction::Decline, content: None, meta: None, }, }) if op_thread_id == thread_id && server_name == "payments" ); } #[tokio::test] async fn inactive_thread_approval_badge_clears_after_turn_completion_notification() -> Result<()> { let mut app = make_test_app().await; let main_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000101").expect("valid thread"); let agent_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000202").expect("valid thread"); app.primary_thread_id = Some(main_thread_id); app.active_thread_id = Some(main_thread_id); app.thread_event_channels .insert(main_thread_id, ThreadEventChannel::new(/*capacity*/ 1)); app.thread_event_channels.insert( agent_thread_id, ThreadEventChannel::new_with_session( /*capacity*/ 4, ThreadSessionState { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::workspace_write(), rollout_path: Some(test_path_buf("/tmp/agent-rollout.jsonl")), ..test_thread_session(agent_thread_id, test_path_buf("/tmp/agent")) }, Vec::new(), ), ); app.agent_navigation.upsert( agent_thread_id, Some("Robie".to_string()), Some("explorer".to_string()), /*is_closed*/ false, ); app.enqueue_thread_request( agent_thread_id, exec_approval_request( agent_thread_id, "turn-approval", "call-approval", /*approval_id*/ None, ), ) .await?; assert_eq!( app.chat_widget.pending_thread_approvals(), &["Robie [explorer]".to_string()] ); app.enqueue_thread_notification( agent_thread_id, turn_completed_notification(agent_thread_id, "turn-approval", TurnStatus::Completed), ) .await?; assert!( app.chat_widget.pending_thread_approvals().is_empty(), "turn completion should clear inactive-thread approval badge immediately" ); Ok(()) } #[tokio::test] async fn inactive_thread_started_notification_initializes_replay_session() -> Result<()> { let mut app = make_test_app().await; let temp_dir = tempdir()?; let main_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000101").expect("valid thread"); let agent_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000202").expect("valid thread"); let primary_cwd = test_path_buf("/tmp/main").abs(); let shared_root = test_path_buf("/tmp/shared").abs(); let primary_session = ThreadSessionState { daybreak_enabled: false, windows_sandbox_host: WindowsSandboxHost::Local, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::workspace_write(), runtime_workspace_roots: vec![primary_cwd.clone(), shared_root.clone()], ..test_thread_session(main_thread_id, primary_cwd.to_path_buf()) }; app.primary_thread_id = Some(main_thread_id); app.active_thread_id = Some(main_thread_id); app.primary_session_configured = Some(primary_session.clone()); app.thread_event_channels.insert( main_thread_id, ThreadEventChannel::new_with_session( /*capacity*/ 4, primary_session.clone(), Vec::new(), ), ); let rollout_path = temp_dir.path().join("agent-rollout.jsonl"); app.enqueue_thread_notification( agent_thread_id, ServerNotification::ThreadStarted(ThreadStartedNotification { thread: Thread { originator: None, environments: Some(vec![codex_app_server_protocol::ThreadEnvironment { environment_id: "remote".to_string(), cwd: test_path_buf("/tmp/agent").abs().into(), runtime_workspace_roots: Vec::new(), }]), id: agent_thread_id.to_string(), extra: None, session_id: agent_thread_id.to_string(), forked_from_id: None, parent_thread_id: None, preview: "agent thread".to_string(), ephemeral: false, section: None, section_entered_at: None, project_id: None, daybreak_enabled: Some(true), history_mode: Default::default(), model_provider: "agent-provider".to_string(), model: Some("gpt-agent".to_string()), reasoning_effort: None, created_at: 1, updated_at: 2, recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: Some(rollout_path.clone()), cwd: test_path_buf("/tmp/agent").abs(), cli_version: "0.0.0".to_string(), source: codex_app_server_protocol::SessionSource::Unknown, can_accept_direct_input: None, thread_source: None, agent_nickname: Some("Robie".to_string()), agent_role: Some("explorer".to_string()), git_info: None, name: Some("agent thread".to_string()), turns: Vec::new(), }, }), ) .await?; let store = app .thread_event_channels .get(&agent_thread_id) .expect("agent thread channel") .store .lock() .await; let session = store.session.clone().expect("inferred session"); drop(store); assert_eq!(session.thread_id, agent_thread_id); assert!(session.daybreak_enabled); assert_eq!(session.windows_sandbox_host, WindowsSandboxHost::Remote); assert_eq!(session.thread_name, Some("agent thread".to_string())); assert_eq!(session.model, "gpt-agent"); assert_eq!(session.model_provider_id, "agent-provider"); assert_eq!(session.approval_policy, primary_session.approval_policy); assert_eq!(session.cwd.as_path(), test_path_buf("/tmp/agent").as_path()); assert_eq!( session.runtime_workspace_roots, vec![test_path_buf("/tmp/agent").abs(), shared_root] ); assert_eq!(session.rollout_path, Some(rollout_path)); assert_eq!( app.agent_navigation.get(&agent_thread_id), Some(&AgentPickerThreadEntry { agent_nickname: Some("Robie".to_string()), agent_role: Some("explorer".to_string()), agent_path: None, is_running: false, is_closed: false, }) ); Ok(()) } #[tokio::test] async fn inactive_thread_started_notification_preserves_primary_model_when_path_missing() -> Result<()> { let mut app = make_test_app().await; let main_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000301").expect("valid thread"); let agent_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000302").expect("valid thread"); let primary_cwd = test_path_buf("/tmp/main").abs(); let primary_session = ThreadSessionState { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::workspace_write(), runtime_workspace_roots: vec![primary_cwd.clone()], ..test_thread_session(main_thread_id, primary_cwd.to_path_buf()) }; app.primary_thread_id = Some(main_thread_id); app.active_thread_id = Some(main_thread_id); app.primary_session_configured = Some(primary_session.clone()); app.thread_event_channels.insert( main_thread_id, ThreadEventChannel::new_with_session( /*capacity*/ 4, primary_session.clone(), Vec::new(), ), ); app.enqueue_thread_notification( agent_thread_id, ServerNotification::ThreadStarted(ThreadStartedNotification { thread: Thread { originator: None, environments: None, id: agent_thread_id.to_string(), extra: None, session_id: agent_thread_id.to_string(), forked_from_id: None, parent_thread_id: None, preview: "agent thread".to_string(), ephemeral: false, section: None, section_entered_at: None, project_id: None, daybreak_enabled: None, history_mode: Default::default(), model_provider: "agent-provider".to_string(), model: None, reasoning_effort: None, created_at: 1, updated_at: 2, recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp/agent").abs(), cli_version: "0.0.0".to_string(), source: codex_app_server_protocol::SessionSource::Unknown, can_accept_direct_input: None, thread_source: None, agent_nickname: Some("Robie".to_string()), agent_role: Some("explorer".to_string()), git_info: None, name: Some("agent thread".to_string()), turns: Vec::new(), }, }), ) .await?; let store = app .thread_event_channels .get(&agent_thread_id) .expect("agent thread channel") .store .lock() .await; let session = store.session.clone().expect("inferred session"); assert_eq!(session.model, primary_session.model); Ok(()) } /// `thread/read` is metadata/replay hydration and does not return a fresh /// server-authored `PermissionProfile`, so it must not reuse the cached primary /// session profile after swapping in the read thread's cwd. #[tokio::test] async fn thread_read_session_state_does_not_reuse_primary_permission_profile() { let mut app = make_test_app().await; let main_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000401").expect("valid thread"); let read_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000402").expect("valid thread"); let primary_cwd = test_path_buf("/tmp/main").abs(); let primary_session = ThreadSessionState { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::workspace_write(), runtime_workspace_roots: vec![primary_cwd.clone()], ..test_thread_session(main_thread_id, primary_cwd.to_path_buf()) }; app.primary_session_configured = Some(primary_session); let thread = Thread { originator: None, environments: None, id: read_thread_id.to_string(), extra: None, session_id: read_thread_id.to_string(), forked_from_id: None, parent_thread_id: None, preview: "read thread".to_string(), ephemeral: false, section: None, section_entered_at: None, project_id: None, daybreak_enabled: None, history_mode: Default::default(), model_provider: "read-provider".to_string(), model: Some("server-reported-model".to_string()), reasoning_effort: None, created_at: 1, updated_at: 2, recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp/read").abs(), cli_version: "0.0.0".to_string(), source: codex_app_server_protocol::SessionSource::Unknown, can_accept_direct_input: None, thread_source: None, agent_nickname: None, agent_role: None, git_info: None, name: Some("read thread".to_string()), turns: Vec::new(), }; let session = app .session_state_for_thread_read(read_thread_id, &thread) .await; assert_eq!(session.thread_id, read_thread_id); assert_eq!(session.model, "server-reported-model"); assert_eq!(session.cwd.as_path(), test_path_buf("/tmp/read").as_path()); assert_eq!( session.runtime_workspace_roots, vec![test_path_buf("/tmp/read").abs()] ); let expected_permission_profile = app .chat_widget .config_ref() .permissions .permission_profile() .clone(); assert_eq!( session.permission_profile, expected_permission_profile, "thread/read does not return fresh server permissions; the fallback profile must use the \ active widget permissions rather than reusing the cached primary session profile" ); app.enqueue_primary_thread_session(session, Vec::new()) .await .expect("switch to read thread"); assert_eq!(app.chat_widget.current_model(), "server-reported-model"); } #[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_app_snapshot!("agent_picker_item_name", snapshot); } #[tokio::test] async fn side_fork_config_is_ephemeral_and_appends_developer_guardrails() { let app = make_test_app().await; let original_approval_policy = app.config.permissions.approval_policy.value(); let original_sandbox_policy = app.config.legacy_sandbox_policy(); 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.legacy_sandbox_policy(), original_sandbox_policy); let developer_instructions = fork_config .developer_instructions .as_deref() .expect("side developer instructions"); 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()); } #[tokio::test] async fn side_fork_config_inherits_parent_thread_runtime_settings() { let mut app = make_test_app().await; app.config.model = Some("persisted-default-model".to_string()); app.config.model_reasoning_effort = Some(ReasoningEffortConfig::Low); let parent_service_tier = ServiceTier::Fast.request_value(); let parent_permission_profile = PermissionProfile::workspace_write(); app.chat_widget.set_model("parent-thread-model"); app.chat_widget .set_reasoning_effort(Some(ReasoningEffortConfig::High)); app.chat_widget .set_service_tier(Some(parent_service_tier.to_string())); app.chat_widget .set_approval_policy(AskForApproval::OnRequest); app.chat_widget .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::legacy( parent_permission_profile.clone(), )) .expect("test permission profile should be accepted"); app.chat_widget .set_approvals_reviewer(ApprovalsReviewer::AutoReview); let fork_config = app.side_fork_config(); assert_eq!( ( fork_config.model.as_deref(), fork_config.model_reasoning_effort, fork_config.service_tier.as_deref(), fork_config.permissions.approval_policy.value(), fork_config.permissions.permission_profile(), fork_config.approvals_reviewer, ), ( Some("parent-thread-model"), Some(ReasoningEffortConfig::High), Some(parent_service_tier), AskForApproval::OnRequest.to_core(), &parent_permission_profile, ApprovalsReviewer::AutoReview, ) ); } #[tokio::test] async fn side_start_block_message_allows_replacing_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)); app.active_thread_id = Some(parent_thread_id); assert_eq!(app.side_start_block_message(), None); app.active_thread_id = Some(side_thread_id); assert_eq!( app.side_start_block_message(), Some( "A side conversation is already open. Press ctrl + c 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 ); app.enqueue_thread_request( parent_thread_id, request_user_input_request(parent_thread_id, "turn-eviction", "input-eviction"), ) .await?; let chunk = "x".repeat(4 * 1024); app.enqueue_thread_notification( parent_thread_id, agent_message_delta_notification( parent_thread_id, "turn-eviction", "item-eviction", &chunk, ), ) .await?; app.enqueue_thread_request( parent_thread_id, exec_approval_request( parent_thread_id, "turn-eviction", "approval-eviction", /*approval_id*/ None, ), ) .await?; let side_footer = |app: &App| { render_bottom_popup(&app.chat_widget, /*width*/ 120) .lines() .find_map(|line| { line.find("Side from main thread") .map(|start| line[start..].trim().to_string()) }) .expect("side conversation footer should be rendered") }; assert_eq!( app.side_threads .get(&side_thread_id) .and_then(|state| state.parent_status), Some(SideParentStatus::NeedsInput) ); let input_footer = side_footer(&app); for _ in 0..64 { app.enqueue_thread_notification( parent_thread_id, agent_message_delta_notification( parent_thread_id, "turn-eviction", "item-eviction", &chunk, ), ) .await?; } assert_eq!( app.side_threads .get(&side_thread_id) .and_then(|state| state.parent_status), Some(SideParentStatus::NeedsApproval) ); let approval_footer = side_footer(&app); assert!( app.thread_event_channels .get(&parent_thread_id) .expect("parent thread channel should exist") .store .lock() .await .has_pending_thread_approvals() ); app.enqueue_thread_notification( parent_thread_id, agent_message_delta_notification( parent_thread_id, "turn-eviction", "item-eviction", &chunk, ), ) .await?; assert_eq!( app.side_threads .get(&side_thread_id) .and_then(|state| state.parent_status), None ); let cleared_footer = side_footer(&app); assert!( !app.thread_event_channels .get(&parent_thread_id) .expect("parent thread channel should exist") .store .lock() .await .has_pending_thread_approvals() ); assert_snapshot!( format!("{input_footer}\n{approval_footer}\n{cleared_footer}"), @" Side from main thread · main needs input · ⌃/ to switch · ⌃c to close Side from main thread · main needs approval · ⌃/ to switch · ⌃c to close Side from main thread · ⌃/ to switch · ⌃c to close " ); Ok(()) } #[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 { daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, forked_from_id: Some(parent_thread_id), fork_parent_title: None, ..test_thread_session(side_thread_id, test_path_buf("/tmp/side")) }; let parent_turn = test_turn( "parent-turn", TurnStatus::Completed, vec![ThreadItemPayload::UserMessage { id: "parent-user".to_string(), client_id: None, 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 { delegated_turns: Vec::new(), session: Some(ThreadSessionState { rollout_path: None, ..test_thread_session(side_thread_id, test_path_buf("/tmp/side")) }), turns: Vec::new(), events: Vec::new(), active_reasoning_item: None, 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 { delegated_turns: Vec::new(), session: Some(ThreadSessionState { daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, forked_from_id: Some(parent_thread_id), fork_parent_title: None, ..test_thread_session(side_thread_id, test_path_buf("/tmp/side")) }), turns: Vec::new(), events: Vec::new(), active_reasoning_item: None, 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 primary_thread_ignores_child_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 sentry_config = toml::from_str::("command = 'true'") .expect("test MCP config should parse") .try_into() .expect("test MCP config should deserialize"); app.config .mcp_servers .set(std::collections::HashMap::from([( "sentry".to_string(), sentry_config, )])) .expect("test MCP servers should accept any configuration"); 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 child_thread_id = ThreadId::new(); app.primary_thread_id = Some(parent_thread_id); app.active_thread_id = Some(parent_thread_id); app.upsert_agent_picker_thread( child_thread_id, /*agent_nickname*/ None, /*agent_role*/ None, /*is_closed*/ false, ); app.handle_app_server_event( &app_server, codex_app_server_client::AppServerEvent::ServerNotification(Box::new( ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { thread_id: Some(child_thread_id.to_string()), name: "sentry".to_string(), status: McpServerStartupState::Failed, error: Some("sentry is not logged in".to_string()), failure_reason: None, }), )), ) .await; assert!(app_event_rx.try_recv().is_err()); let mut child_snapshot = app .thread_event_channels .get(&child_thread_id) .expect("child thread channel should be created") .store .lock() .await .snapshot(); assert!( matches!( child_snapshot.events.as_slice(), [ThreadBufferedEvent::Notification(notification)] if matches!( notification.as_ref(), ServerNotification::McpServerStatusUpdated(_) ) ), "child MCP startup notification should be buffered for the child thread" ); app.apply_refreshed_snapshot_thread( child_thread_id, AppServerStartedThread { session: test_thread_session(child_thread_id, test_path_buf("/tmp/child")), turns: Vec::new(), blocks_direct_input: false, task_tools_available: false, }, &mut child_snapshot, ) .await; app.replay_thread_snapshot(child_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.transcript_lines(/*width*/ 120), )); } } let rendered = rendered_cells.join("\n"); assert_eq!(app.chat_widget.thread_id(), Some(child_thread_id)); assert_eq!(rendered.matches("sentry is not logged in").count(), 1); assert_eq!( rendered .matches("MCP startup incomplete (failed: sentry)") .count(), 1 ); } #[tokio::test] async fn app_scoped_mcp_startup_notifications_do_not_render_in_active_thread() { 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 thread_id = ThreadId::new(); app.primary_thread_id = Some(thread_id); app.active_thread_id = Some(thread_id); app.handle_app_server_event( &app_server, codex_app_server_client::AppServerEvent::ServerNotification(Box::new( ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { thread_id: None, name: "sentry".to_string(), status: McpServerStartupState::Failed, error: Some("sentry is not logged in".to_string()), failure_reason: None, }), )), ) .await; assert!(app_event_rx.try_recv().is_err()); assert_eq!( app.chat_widget.active_cell_transcript_lines(/*width*/ 120), None ); } #[tokio::test] async fn active_side_thread_renders_live_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 sentry_config = toml::from_str::("command = 'true'") .expect("test MCP config should parse") .try_into() .expect("test MCP config should deserialize"); app.config .mcp_servers .set(std::collections::HashMap::from([( "sentry".to_string(), sentry_config, )])) .expect("test MCP servers should accept any configuration"); 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.side_threads .insert(side_thread_id, SideThreadState::new(parent_thread_id)); app.ensure_thread_channel(side_thread_id); app.activate_thread_channel(side_thread_id).await; app.replay_thread_snapshot( ThreadEventSnapshot { delegated_turns: Vec::new(), session: Some(test_thread_session( side_thread_id, test_path_buf("/tmp/side"), )), turns: Vec::new(), events: Vec::new(), active_reasoning_item: None, input_state: None, }, /*resume_restored_queue*/ false, ); app.sync_side_thread_ui(); for status in [ McpServerStartupState::Starting, McpServerStartupState::Failed, ] { app.handle_app_server_event( &app_server, codex_app_server_client::AppServerEvent::ServerNotification(Box::new( ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { thread_id: Some(side_thread_id.to_string()), name: "sentry".to_string(), status, error: matches!(status, McpServerStartupState::Failed) .then(|| "sentry is not logged in".to_string()), failure_reason: None, }), )), ) .await; } let mut active_thread_events = Vec::new(); let active_thread_rx = app .active_thread_rx .as_mut() .expect("side thread receiver should be active"); while let Ok(event) = active_thread_rx.try_recv() { active_thread_events.push(event); } for event in active_thread_events { app.handle_thread_event_now(event); } let mut tui = crate::tui::test_support::make_test_tui().expect("test tui"); 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.transcript_lines(/*width*/ 120), )); app.insert_history_cell(&mut tui, cell); } } assert_eq!(history_cell::warning_count(&app.transcript_cells), 1); let rendered = rendered_cells.join("\n"); assert!(app.chat_widget.side_conversation_active()); assert_eq!(rendered.matches("sentry is not logged in").count(), 1); assert_eq!( rendered .matches("MCP startup incomplete (failed: sentry)") .count(), 1 ); } #[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_only_closes_the_active_side() { 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) ); app.active_thread_id = Some(parent_thread_id); assert_eq!( app.side_thread_to_discard_after_switch(ThreadId::new()), None ); assert_eq!( app.side_thread_to_discard_after_switch(side_thread_id), None ); } #[tokio::test] async fn discard_side_thread_removes_agent_navigation_entry() -> Result<()> { Box::pin(async { 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(()) }) .await } #[tokio::test] async fn discard_side_thread_keeps_local_state_when_server_close_fails() -> Result<()> { Box::pin(async { 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(()) }) .await } #[tokio::test] async fn background_side_cleanup_removes_local_state_and_ignores_late_events() -> Result<()> { let (mut app, mut events, _ops) = make_test_app_with_channels().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(parent_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.dynamic_tool_tasks.insert( AppServerRequestId::Integer(123), ( side_thread_id.to_string(), tokio::spawn(std::future::pending::<()>()), ), ); app.discard_side_thread_in_background(&mut app_server, side_thread_id) .await; assert_matches!( events.try_recv(), Ok(AppEvent::DynamicToolCallCompleted { response, .. }) if !response.success ); assert!(app.dynamic_tool_tasks.is_empty()); assert_eq!(app.active_thread_id, Some(parent_thread_id)); 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); assert!(app.abandoned_side_threads.contains(&side_thread_id)); app.enqueue_thread_notification( side_thread_id, agent_message_delta_notification(side_thread_id, "turn-1", "item-1", "late"), ) .await?; assert!(!app.thread_event_channels.contains_key(&side_thread_id)); app.handle_app_server_event( &app_server, codex_app_server_client::AppServerEvent::ServerRequest(Box::new(exec_approval_request( side_thread_id, "turn-1", "item-1", Some("approval-1"), ))), ) .await; let resolution = app .pending_app_server_requests .take_resolution( &side_thread_id.to_string(), Op::ExecApproval { id: "approval-1".to_string(), turn_id: None, decision: codex_app_server_protocol::CommandExecutionApprovalDecision::Accept, }, ) .expect("approval resolution should serialize"); assert_eq!(resolution, None); 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<()> { let mut app = make_test_app().await; app.active_thread_id = Some(ThreadId::new()); app.primary_thread_id = Some(ThreadId::new()); assert_eq!( app.active_non_primary_shutdown_target(&ServerNotification::SkillsChanged( codex_app_server_protocol::SkillsChangedNotification {}, )), None ); Ok(()) } #[tokio::test] async fn active_non_primary_shutdown_target_returns_none_for_primary_thread_shutdown() -> Result<()> { let mut app = make_test_app().await; let thread_id = ThreadId::new(); app.active_thread_id = Some(thread_id); app.primary_thread_id = Some(thread_id); assert_eq!( app.active_non_primary_shutdown_target(&thread_closed_notification(thread_id)), None ); Ok(()) } #[tokio::test] async fn active_non_primary_shutdown_target_returns_ids_for_non_primary_shutdown() -> Result<()> { let mut app = make_test_app().await; let active_thread_id = ThreadId::new(); let primary_thread_id = ThreadId::new(); app.active_thread_id = Some(active_thread_id); app.primary_thread_id = Some(primary_thread_id); assert_eq!( app.active_non_primary_shutdown_target(&thread_closed_notification(active_thread_id)), Some((active_thread_id, primary_thread_id)) ); Ok(()) } #[tokio::test] async fn active_non_primary_shutdown_target_returns_none_when_shutdown_exit_is_pending() -> Result<()> { let mut app = make_test_app().await; let active_thread_id = ThreadId::new(); let primary_thread_id = ThreadId::new(); app.active_thread_id = Some(active_thread_id); app.primary_thread_id = Some(primary_thread_id); app.pending_shutdown_exit_thread_id = Some(active_thread_id); assert_eq!( app.active_non_primary_shutdown_target(&thread_closed_notification(active_thread_id)), None ); Ok(()) } #[tokio::test] async fn active_non_primary_shutdown_target_still_switches_for_other_pending_exit_thread() -> Result<()> { let mut app = make_test_app().await; let active_thread_id = ThreadId::new(); let primary_thread_id = ThreadId::new(); app.active_thread_id = Some(active_thread_id); app.primary_thread_id = Some(primary_thread_id); app.pending_shutdown_exit_thread_id = Some(ThreadId::new()); assert_eq!( app.active_non_primary_shutdown_target(&thread_closed_notification(active_thread_id)), Some((active_thread_id, primary_thread_id)) ); Ok(()) } async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String { let mut app = make_test_app().await; app.config.cwd = test_path_buf("/tmp/project").abs(); app.chat_widget.set_model("gpt-test"); app.chat_widget .set_reasoning_effort(Some(ReasoningEffortConfig::High)); let story_part_one = "In the cliffside town of Bracken Ferry, the lighthouse had been dark for \ nineteen years, and the children were told it was because the sea no longer wanted a \ guide. Mara, who repaired clocks for a living, found that hard to believe. Every dawn she \ heard the gulls circling the empty tower, and every dusk she watched ships hesitate at the \ mouth of the bay as if listening for a signal that never came. When an old brass key fell \ out of a cracked parcel in her workshop, tagged only with the words 'for the lamp room,' \ she decided to climb the hill and see what the town had forgotten."; let story_part_two = "Inside the lighthouse she found gears wrapped in oilcloth, logbooks filled \ with weather notes, and a lens shrouded beneath salt-stiff canvas. The mechanism was not \ broken, only unfinished. Someone had removed the governor spring and hidden it in a false \ drawer, along with a letter from the last keeper admitting he had darkened the light on \ purpose after smugglers threatened his family. Mara spent the night rebuilding the clockwork \ from spare watch parts, her fingers blackened with soot and grease, while a storm gathered \ over the water and the harbor bells began to ring."; let story_part_three = "At midnight the first squall hit, and the fishing boats returned early, \ blind in sheets of rain. Mara wound the mechanism, set the teeth by hand, and watched the \ great lens begin to turn in slow, certain arcs. The beam swept across the bay, caught the \ whitecaps, and reached the boats just as they were drifting toward the rocks below the \ eastern cliffs. In the morning the town square was crowded with wet sailors, angry elders, \ and wide-eyed children, but when the oldest captain placed the keeper's log on the fountain \ and thanked Mara for relighting the coast, nobody argued. By sunset, Bracken Ferry had a \ lighthouse again, and Mara had more clocks to mend than ever because everyone wanted \ something in town to keep better time."; let user_cell = |text: &str| -> Arc { Arc::new(UserHistoryCell { presentation: Default::default(), message: text.to_string(), text_elements: Vec::new(), local_image_paths: Vec::new(), remote_image_urls: Vec::new(), }) as Arc }; let agent_cell = |text: &str| -> Arc { Arc::new(AgentMessageCell::new( vec![Line::from(text.to_string())], /*is_first_line*/ true, )) as Arc }; let make_header = |is_first| -> Arc { let session = ThreadSessionState { daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id: ThreadId::new(), 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, permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/tmp/project").abs(), runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::High), collaboration_mode: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), }; Arc::new(new_session_info( app.chat_widget.config_ref(), &app.local_settings, app.chat_widget.current_model(), &session.model, &session, is_first, /*tooltip_override*/ None, /*auth_plan*/ None, )) as Arc }; app.transcript_cells = vec![ make_header(true), Arc::new(crate::history_cell::new_info_event( "startup tip that used to replay".to_string(), /*hint*/ None, )) as Arc, user_cell("Tell me a long story about a town with a dark lighthouse."), agent_cell(story_part_one), user_cell("Continue the story and reveal why the light went out."), agent_cell(story_part_two), user_cell("Finish the story with a storm and a resolution."), agent_cell(story_part_three), ]; app.has_emitted_history_lines = true; let rendered = app .clear_ui_header_lines_with_version(/*width*/ 80, "") .iter() .map(|line| { line.spans .iter() .map(|span| span.content.as_ref()) .collect::() }) .collect::>() .join("\n"); assert!( !rendered.contains("startup tip that used to replay"), "clear header should not replay startup notices" ); assert!( !rendered.contains("Bracken Ferry"), "clear header should not replay prior conversation turns" ); rendered } #[tokio::test] #[cfg_attr( target_os = "windows", ignore = "snapshot path rendering differs on Windows" )] async fn clear_ui_after_long_transcript_snapshots_fresh_header_only() { let rendered = render_clear_ui_header_after_long_transcript_for_snapshot().await; assert_app_snapshot!("clear_ui_after_long_transcript_fresh_header_only", rendered); } #[tokio::test] async fn ctrl_l_preserves_the_transcript_and_draft_after_clearing_the_screen() -> Result<()> { let (mut app, _codex_home, thread_id) = pagination_completion_tests::completed_history_app(&["Oldest", "Newest"]).await?; let mut tui = crate::tui::test_support::make_test_tui()?; let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?; let started = app_server .resume_thread( &app.local_settings, app.config.clone(), thread_id, crate::app_server_session::ResumeModelSettings::RestoreFromThread, ) .await?; app.enqueue_primary_thread_session(started.session, started.turns) .await?; app.scrollback_has_older_history = app_server.has_older_history(thread_id); assert!(app.scrollback_has_older_history); let cursor = app_server .begin_older_history_page(thread_id) .expect("older page"); let page = app_server .thread_items_page( thread_id, /*turn_id*/ None, Some(cursor.clone()), /*limit*/ 100, ) .await?; app.chat_widget.set_model("gpt-test"); app.chat_widget.insert_str("draft survives"); app.transcript_cells = vec![Arc::new(history_cell::new_user_prompt( "Earlier exchange".into(), Vec::new(), Vec::new(), Vec::new(), ))]; tui.set_owned_screen(/*owned*/ true)?; let size = ratatui::layout::Size::new(/*width*/ 80, /*height*/ 12); app.render_owned_transcript(&mut tui, size)?; app.handle_tui_event( &mut tui, &mut app_server, TuiEvent::Key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL)), ) .await?; app.render_owned_transcript(&mut tui, size)?; app.handle_older_history_page( &mut tui, &mut app_server, thread_id, &cursor, Ok(page.clone()), ) .await?; assert!(app_server.has_older_history(thread_id)); app.request_owned_history(&mut tui, &mut app_server); assert!(!app_server.is_older_history_page_pending(thread_id, &cursor)); app.insert_history_cell( &mut tui, Box::new(history_cell::PlainHistoryCell::new(vec![ "New exchange".into(), ])), ); let size = Size::new(/*width*/ 64, /*height*/ 16); tui.screen_size_for_event(&TuiEvent::Resize(size))?; app.render_owned_transcript(&mut tui, size)?; let compact = transcript_composer::buffer_text( crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal), ); assert!(compact.contains("OpenAI Codex")); assert!(compact.contains("New exchange")); assert!(!compact.contains("Earlier exchange")); let ctrl_t = KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL); app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(ctrl_t)) .await?; let area = Rect::new(/*x*/ 0, /*y*/ 0, size.width, size.height); let mut buffer = ratatui::buffer::Buffer::empty(area); app.transcript_view .render(area, &mut buffer, &app.transcript_cells); assert_app_snapshot!( "ctrl_l_detailed_transcript", transcript_composer::buffer_text(&buffer).trim() ); let home = KeyEvent::new(KeyCode::Home, KeyModifiers::CONTROL); app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(home)) .await?; assert_eq!( app.transcript_view.history, crate::pager_overlay::TranscriptHistoryState::LoadingBeginning ); assert!(app_server.is_older_history_page_pending(thread_id, &cursor)); app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(ctrl_t)) .await?; app.handle_older_history_page(&mut tui, &mut app_server, thread_id, &cursor, Ok(page)) .await?; for event in [TuiEvent::Draw, TuiEvent::Key(home)] { app.handle_tui_event(&mut tui, &mut app_server, event) .await?; app.render_owned_transcript(&mut tui, size)?; let compact = transcript_composer::buffer_text( crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal), ); assert!(!compact.contains("Earlier exchange")); } // Native scrollback replay uses a separate renderer when the terminal changes width. app.local_settings.tui.terminal_resize_reflow_max_rows = None; let replay = app .render_transcript_lines_for_reflow(size.width) .lines .iter() .map(rendered_line_text) .collect::>() .join("\n"); assert!(replay.contains("New exchange")); assert!(!replay.contains("Earlier exchange")); assert_eq!( app.chat_widget.composer_text_with_pending(), "draft survives" ); assert!(tui.is_owned_screen()); tui.set_owned_screen(/*owned*/ false)?; app_server.shutdown().await?; Ok(()) } async fn make_test_app() -> Box { let (mut chat_widget, app_event_tx, _rx, _op_rx) = boxed_future!(make_chatwidget_manual_with_sender()).await; let test_codex_home = chat_widget.test_codex_home.take(); let config = chat_widget.config_ref().clone(); let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone()); let model = get_model_offline_for_tests(config.model.as_deref()); let session_telemetry = test_session_telemetry(&config, model.as_str()); Box::new(App { feature_write_lock: Arc::default(), config_notification_test_generation: 0, config_notification_test_pending: None, model_catalog: chat_widget.model_catalog(), session_telemetry, terminal_size_tags: Vec::new(), app_event_tx, chat_widget, workspace_command_runner: None, launch_cwd: config.cwd.to_path_buf(), runtime_working_directory_override: None, local_settings: crate::local_settings::LocalSettings::from(&config), config, state_db: None, cli_kv_overrides: Vec::new(), harness_overrides: ConfigOverrides::default(), loader_overrides: LoaderOverrides::without_managed_config_for_tests(), cloud_config_bundle: CloudConfigBundleLoader::default(), runtime_approval_policy_override: None, runtime_approvals_reviewer_override: None, runtime_permission_profile_override: None, file_search, transcript_cells: Vec::new(), native_history: Default::default(), turn_tips: Default::default(), transcript_view: Default::default(), last_rendered_history_tail: None, last_thread_usage_status_cell: None, pending_thread_usage_history_refresh: false, overlay: None, retained_analytics: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, transcript_reflow: TranscriptReflowState::default(), initial_history_replay_buffer: None, pending_thread_switch_resets: 0, scrollback_has_older_history: false, enhanced_keys_supported: false, keymap: crate::keymap::RuntimeKeymap::defaults(), key_chord_matcher: crate::keymap::KeyChordMatcher::default(), routed_key_activation: None, commit_animation: None, status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), skill_load_warnings: SkillLoadWarningState::default(), backtrack: BacktrackState::default(), backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), feedback_audience: FeedbackAudience::External, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), app_server_target: crate::AppServerTarget::Embedded, reconnect: Default::default(), pending_right_click_paste: None, right_click_paste_environment: super::right_click_paste::PasteEnvironment { primary: false, platform_default: true, ssh: false, wsl: false, vscode: crate::tui::VscodeDetection::Other, }, daemon_cli_executable: None, pending_update_action: None, daemon_restart: None, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), thread_event_channels: HashMap::new(), pending_realtime_speech_replay: HashMap::new(), pending_realtime_transcript_replay: HashMap::new(), realtime_replay_order: VecDeque::new(), background_voice: None, background_voice_error: None, temporary_structured_requests: HashMap::new(), pending_thread_titles: HashMap::new(), thread_event_listener_tasks: HashMap::new(), agent_navigation: AgentNavigationState::default(), pending_server_profiles: HashMap::new(), agents_overview: Default::default(), side_threads: HashMap::new(), abandoned_side_threads: HashSet::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(), dynamic_tool_status_updates: tokio::sync::broadcast::channel(/*capacity*/ 64).0, dynamic_tool_tasks: HashMap::new(), pending_startup_thread_start: false, pending_server_version_notice: None, pending_open_resume_picker: false, pending_managed_worktree_creation: false, pending_working_directory_change: None, pending_start_managed_worktree: None, pending_managed_worktree_created: None, pending_managed_worktree_transition: None, pending_managed_worktree_attach: None, startup_protected_input_boundary: false, startup_pending_protected_request: false, account_email_request_id: None, rate_limit_hard_stop_generation: 0, rate_limit_refresh_state: Default::default(), pending_mcp_login_start: None, active_mcp_login_ids: HashMap::new(), pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), recap: recap::RecapState::default(), _test_codex_home: test_codex_home, }) } pub(super) async fn make_test_app_with_channels() -> ( Box, tokio::sync::mpsc::UnboundedReceiver, tokio::sync::mpsc::UnboundedReceiver, ) { let (mut chat_widget, app_event_tx, rx, op_rx) = boxed_future!(make_chatwidget_manual_with_sender()).await; let test_codex_home = chat_widget.test_codex_home.take(); let config = chat_widget.config_ref().clone(); let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone()); let model = get_model_offline_for_tests(config.model.as_deref()); let session_telemetry = test_session_telemetry(&config, model.as_str()); ( Box::new(App { feature_write_lock: Arc::default(), config_notification_test_generation: 0, config_notification_test_pending: None, model_catalog: chat_widget.model_catalog(), session_telemetry, terminal_size_tags: Vec::new(), app_event_tx, chat_widget, workspace_command_runner: None, launch_cwd: config.cwd.to_path_buf(), runtime_working_directory_override: None, local_settings: crate::local_settings::LocalSettings::from(&config), config, state_db: None, cli_kv_overrides: Vec::new(), harness_overrides: ConfigOverrides::default(), loader_overrides: LoaderOverrides::without_managed_config_for_tests(), cloud_config_bundle: CloudConfigBundleLoader::default(), runtime_approval_policy_override: None, runtime_approvals_reviewer_override: None, runtime_permission_profile_override: None, file_search, transcript_cells: Vec::new(), native_history: Default::default(), turn_tips: Default::default(), transcript_view: Default::default(), last_rendered_history_tail: None, last_thread_usage_status_cell: None, pending_thread_usage_history_refresh: false, overlay: None, retained_analytics: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, transcript_reflow: TranscriptReflowState::default(), initial_history_replay_buffer: None, pending_thread_switch_resets: 0, scrollback_has_older_history: false, enhanced_keys_supported: false, keymap: crate::keymap::RuntimeKeymap::defaults(), key_chord_matcher: crate::keymap::KeyChordMatcher::default(), routed_key_activation: None, commit_animation: None, status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), skill_load_warnings: SkillLoadWarningState::default(), backtrack: BacktrackState::default(), backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), feedback_audience: FeedbackAudience::External, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), app_server_target: crate::AppServerTarget::Embedded, reconnect: Default::default(), pending_right_click_paste: None, right_click_paste_environment: super::right_click_paste::PasteEnvironment { primary: false, platform_default: true, ssh: false, wsl: false, vscode: crate::tui::VscodeDetection::Other, }, daemon_cli_executable: None, pending_update_action: None, daemon_restart: None, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), thread_event_channels: HashMap::new(), pending_realtime_speech_replay: HashMap::new(), pending_realtime_transcript_replay: HashMap::new(), realtime_replay_order: VecDeque::new(), background_voice: None, background_voice_error: None, temporary_structured_requests: HashMap::new(), pending_thread_titles: HashMap::new(), thread_event_listener_tasks: HashMap::new(), agent_navigation: AgentNavigationState::default(), pending_server_profiles: HashMap::new(), agents_overview: Default::default(), side_threads: HashMap::new(), abandoned_side_threads: HashSet::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(), dynamic_tool_status_updates: tokio::sync::broadcast::channel(/*capacity*/ 64).0, dynamic_tool_tasks: HashMap::new(), pending_startup_thread_start: false, pending_server_version_notice: None, pending_open_resume_picker: false, pending_managed_worktree_creation: false, pending_working_directory_change: None, pending_start_managed_worktree: None, pending_managed_worktree_created: None, pending_managed_worktree_transition: None, pending_managed_worktree_attach: None, startup_protected_input_boundary: false, startup_pending_protected_request: false, account_email_request_id: None, rate_limit_hard_stop_generation: 0, rate_limit_refresh_state: Default::default(), pending_mcp_login_start: None, active_mcp_login_ids: HashMap::new(), pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), recap: recap::RecapState::default(), _test_codex_home: test_codex_home, }), rx, op_rx, ) } #[tokio::test] async fn set_thread_goal_draft_materializes_long_objective_and_confirms_before_paste() -> 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 started = app_server .start_thread(app.chat_widget.config_ref()) .await?; let thread_id = started.session.thread_id; app.enqueue_primary_thread_session(started.session, started.turns) .await?; let objective = "x".repeat(MAX_THREAD_GOAL_OBJECTIVE_CHARS + 1); app.set_thread_goal_draft( &mut app_server, thread_id, crate::goal_files::GoalDraft { objective: objective.clone(), ..Default::default() }, crate::app_event::ThreadGoalSetMode::ConfirmIfExists, ) .await; let response = app_server.thread_goal_get(thread_id).await?; let goal = response.goal.expect("goal should be set"); let saved_objective = goal.objective.clone(); let codex_home = app_server .codex_home_path(&app.chat_widget.config_ref().codex_home) .expect("codex home"); assert!(goal_files::objective_file_path(&goal.objective, Some(&codex_home)).is_some()); assert_eq!( goal_files::objective_text_for_edit(&mut app_server, Some(&codex_home), &goal.objective) .await .expect("managed goal file should be readable"), objective ); let is_managed = |home: &AppServerPath, path: &str| { let reference = goal_files::objective_file_reference(&AppServerPath::from_app_server(path)) .expect("goal objective reference"); goal_files::objective_file_path(&reference, Some(home)).is_some() }; let suffix = "attachments/00000000-0000-4000-8000-000000000000/goal-objective.md"; for path in [ format!("/tmp/{suffix}"), format!("{codex_home}/../other/{suffix}"), format!("{codex_home}/other/{suffix}"), ] { assert!(!is_managed(&codex_home, &path)); } assert!(!is_managed( &AppServerPath::from_app_server("/tmp/codex\\home"), &format!("/tmp/codex/home/{suffix}") )); let unix_path = AppServerPath::from_app_server("/tmp/codex\\").join("a"); assert_eq!(unix_path.as_str(), "/tmp/codex\\/a"); let attachments_dir = app.chat_widget.config_ref().codex_home.join("attachments"); let attachment_count = std::fs::read_dir(&attachments_dir)?.count(); let placeholder = "[Pasted Content 5 chars]"; let paste_draft = crate::goal_files::GoalDraft { objective: format!("Use {placeholder}"), text_elements: vec![TextElement::new( (4..4 + placeholder.len()).into(), Some(placeholder.to_string()), )], pending_pastes: vec![(placeholder.to_string(), "hello".to_string())], ..Default::default() }; app.set_thread_goal_draft( &mut app_server, thread_id, paste_draft.clone(), crate::app_event::ThreadGoalSetMode::ConfirmIfExists, ) .await; assert_eq!( std::fs::read_dir(&attachments_dir)?.count(), attachment_count ); assert_eq!( app_server .thread_goal_get(thread_id) .await? .goal .expect("goal should still be set") .objective, saved_objective ); app.set_thread_goal_draft( &mut app_server, thread_id, paste_draft, crate::app_event::ThreadGoalSetMode::ReplaceExisting, ) .await; let goal = app_server .thread_goal_get(thread_id) .await? .goal .expect("replacement goal should be set"); let paste_path = goal .objective .strip_prefix("Use pasted text file: ") .and_then(|text| text.strip_suffix(". Read this file before continuing.")) .expect("paste file reference"); assert_eq!(std::fs::read_to_string(paste_path)?, "hello"); let attachment_count = std::fs::read_dir(&attachments_dir)?.count(); let stale_paste = (placeholder.to_string(), "hello".to_string()); app.set_thread_goal_draft( &mut app_server, thread_id, crate::goal_files::GoalDraft { objective: "small goal".to_string(), pending_pastes: vec![stale_paste], ..Default::default() }, crate::app_event::ThreadGoalSetMode::ReplaceExisting, ) .await; assert_eq!( std::fs::read_dir(&attachments_dir)?.count(), attachment_count ); let whitespace_placeholder = "[Pasted Content 3 chars]"; app.set_thread_goal_draft( &mut app_server, thread_id, crate::goal_files::GoalDraft { objective: whitespace_placeholder.to_string(), text_elements: vec![TextElement::new( (0..whitespace_placeholder.len()).into(), Some(whitespace_placeholder.to_string()), )], pending_pastes: vec![(whitespace_placeholder.to_string(), " \n\t".to_string())], ..Default::default() }, crate::app_event::ThreadGoalSetMode::ReplaceExisting, ) .await; assert_eq!( std::fs::read_dir(&attachments_dir)?.count(), attachment_count ); assert_eq!( app_server .thread_goal_get(thread_id) .await? .goal .expect("small goal should remain set") .objective, "small goal" ); let image_dir = tempfile::tempdir()?; let image_path = image_dir.path().join("local-image.png"); std::fs::write(&image_path, b"png bytes")?; let image_placeholder = "[Image #3]"; app.set_thread_goal_draft( &mut app_server, thread_id, crate::goal_files::GoalDraft { objective: format!("Describe {image_placeholder}"), text_elements: vec![TextElement::new( (9..9 + image_placeholder.len()).into(), Some(image_placeholder.to_string()), )], local_images: vec![crate::bottom_pane::LocalImageAttachment { placeholder: image_placeholder.to_string(), path: image_path, }], remote_image_urls: vec![ "https://example.com/first.png".to_string(), "https://example.com/second.png".to_string(), ], ..Default::default() }, crate::app_event::ThreadGoalSetMode::ReplaceExisting, ) .await; let objective = app_server .thread_goal_get(thread_id) .await? .goal .expect("image goal should be set") .objective; let copied_image = objective .strip_prefix("Describe image file: ") .and_then(|text| text.split_once("\n\n")) .map(|(path, _)| path) .expect("copied image path"); assert_eq!(std::fs::read(copied_image)?, b"png bytes"); assert!(objective.contains( "Referenced image URLs:\n- [Image #1]: https://example.com/first.png\n- [Image #2]: https://example.com/second.png" )); app_server.shutdown().await?; Ok(()) } #[tokio::test] async fn replace_goal_confirmation_snapshot() { let mut app = make_test_app().await; app.show_replace_thread_goal_confirmation( ThreadId::new(), goal_files::GoalDraft { objective: "New goal".to_string(), ..Default::default() }, ); assert_app_snapshot!( "replace_goal_confirmation", render_bottom_popup(&app.chat_widget, /*width*/ 80) ); } pub(super) fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState { ThreadSessionState { daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, 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, permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: cwd.abs(), runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, collaboration_mode: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), } } fn plain_line_cell(text: impl Into) -> Arc { Arc::new(PlainHistoryCell::new(vec![Line::from(text.into())])) as Arc } #[tokio::test] async fn app_server_thread_replacement_clears_previous_transcript_before_replay() -> Result<()> { let (mut app, mut events, _op_rx) = make_test_app_with_channels().await; app.local_settings.tui.show_tooltips = false; let previous_thread_id = ThreadId::new(); app.enqueue_primary_thread_session( test_thread_session(previous_thread_id, test_path_buf("/tmp/previous")), Vec::new(), ) .await?; while events.try_recv().is_ok() {} app.transcript_cells = vec![plain_line_cell("Previous thread transcript")]; app.deferred_history_lines = vec![Line::from("Previous pending history").into()]; let mut tui = crate::tui::test_support::make_test_tui()?; tui.insert_history_lines(vec![Line::from("Previous pending history")]); app.chat_widget .add_plain_history_lines(vec![Line::from("Previous queued history")]); app.open_transcript_overlay(&mut tui); assert!(tui.is_alt_screen_active()); let next_thread_id = ThreadId::new(); app.replace_chat_widget_with_app_server_thread( &mut tui, AppServerStartedThread { session: test_thread_session(next_thread_id, test_path_buf("/tmp/next")), turns: vec![test_turn( "next-turn", TurnStatus::Completed, vec![ThreadItemPayload::UserMessage { id: "next-user".to_string(), client_id: None, content: vec![AppServerUserInput::Text { text: "Next thread prompt".to_string(), text_elements: Vec::new(), }], }], )], blocks_direct_input: false, task_tools_available: false, }, session_lifecycle::ThreadAttachPresentation::SessionLineage, /*initial_user_message*/ None, ) .await?; assert_eq!(app.chat_widget.thread_id(), Some(next_thread_id)); assert!(!tui.is_alt_screen_active()); assert!(app.transcript_cells.is_empty()); assert!(app.deferred_history_lines.is_empty()); let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?; // The reset must still run if the transport disconnects before queued events are handled. app.reconnect.offline = true; while let Ok(event) = events.try_recv() { app.handle_event(&mut tui, &mut app_server, event).await?; } let replayed_prompts = app .transcript_cells .iter() .filter_map(|cell| { cell.as_any() .downcast_ref::() .map(|user| user.message.as_str()) }) .collect::>(); assert_snapshot!(replayed_prompts.join("\n"), @"Next thread prompt"); assert!(app.transcript_cells.iter().all(|cell| { !lines_to_single_string(&cell.display_lines(/*width*/ 80)) .contains("Previous queued history") })); let rendered = app .render_transcript_lines_for_reflow(/*width*/ 80) .lines .iter() .map(|line| { let text = rendered_line_text(line); if text.trim() == test_path_buf("/tmp/next").display().to_string() { " ".to_string() } else { text } }) .collect::>() .join("\n"); assert!(!rendered.contains("Previous thread transcript")); assert!(!rendered.contains("Previous queued history")); assert_snapshot!(rendered); app_server.shutdown().await?; Ok(()) } #[tokio::test] async fn snapshot_thread_switch_discards_queued_previous_history() -> Result<()> { let (mut app, mut events, _op_rx) = make_test_app_with_channels().await; app.transcript_cells = vec![plain_line_cell("Previous thread transcript")]; app.chat_widget .add_plain_history_lines(vec![Line::from("Previous queued history")]); let mut tui = crate::tui::test_support::make_test_tui()?; let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?; let next_thread_id = ThreadId::new(); app.render_thread_snapshot( &mut tui, &app_server, next_thread_id, ThreadEventSnapshot { delegated_turns: Vec::new(), session: Some(test_thread_session( next_thread_id, test_path_buf("/tmp/next"), )), turns: vec![test_turn( "next-turn", TurnStatus::Completed, vec![ThreadItemPayload::UserMessage { id: "next-user".to_string(), client_id: None, content: vec![AppServerUserInput::Text { text: "Next thread prompt".to_string(), text_elements: Vec::new(), }], }], )], events: Vec::new(), active_reasoning_item: None, input_state: None, }, /*resume_restored_queue*/ false, )?; assert_eq!(app.pending_thread_switch_resets, 1); while let Ok(event) = events.try_recv() { app.handle_event(&mut tui, &mut app_server, event).await?; } assert_eq!(app.pending_thread_switch_resets, 0); let rendered = app .render_transcript_lines_for_reflow(/*width*/ 80) .lines .iter() .map(rendered_line_text) .collect::>() .join("\n"); assert!(!rendered.contains("Previous thread transcript")); assert!(!rendered.contains("Previous queued history")); assert!(rendered.contains("Next thread prompt")); app_server.shutdown().await?; Ok(()) } fn rendered_line_text(line: &crate::terminal_hyperlinks::HyperlinkLine) -> String { line.line .spans .iter() .map(|span| span.content.as_ref()) .collect() } #[tokio::test] async fn capped_resize_reflow_renders_recent_suffix_only() { let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; app.local_settings.tui.terminal_resize_reflow_max_rows = Some(5); app.transcript_cells = (0..20) .map(|i| plain_line_cell(format!("cell {i}"))) .collect(); let rendered = app.render_transcript_lines_for_reflow(/*width*/ 80); assert_eq!(rendered.lines.len(), 5); assert_eq!( rendered .lines .iter() .map(rendered_line_text) .collect::>(), vec![ "Earlier messages are available — press ⌃t to view the full transcript".to_string(), String::new(), "cell 18".to_string(), String::new(), "cell 19".to_string(), ] ); } #[tokio::test] async fn uncapped_resize_reflow_renders_all_cells_when_row_cap_absent() { let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; app.local_settings.tui.terminal_resize_reflow_max_rows = Some(0); app.transcript_cells = (0..20) .map(|i| plain_line_cell(format!("cell {i}"))) .collect(); let rendered = app.render_transcript_lines_for_reflow(/*width*/ 80); assert_eq!(rendered.lines.len(), 39); assert_eq!(rendered_line_text(&rendered.lines[0]), "cell 0"); assert_eq!(rendered_line_text(&rendered.lines[38]), "cell 19"); } #[tokio::test] async fn resize_reflow_wraps_transcript_early_when_pet_is_enabled() { let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; app.local_settings.tui.terminal_resize_reflow_max_rows = Some(0); app.transcript_cells = vec![Arc::new(AgentMarkdownCell::new( "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda".to_string(), Path::new("/tmp"), ))]; let without_pet = app.render_transcript_lines_for_reflow(/*width*/ 40); app.chat_widget .set_pet_image_support_for_tests(crate::pets::PetImageSupport::Supported( crate::pets::ImageProtocol::Kitty, )); app.chat_widget .install_test_ambient_pet_for_tests(/*animations_enabled*/ false); let width = app.chat_widget.history_wrap_width(/*width*/ 40); assert!(width < 40); let with_pet = app.render_transcript_lines_for_reflow(width); assert!( with_pet.lines.len() > without_pet.lines.len(), "expected pet-enabled transcript reflow to wrap earlier" ); } #[tokio::test] async fn closing_fullscreen_inline_overlay_restores_history_once() -> Result<()> { let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; app.transcript_cells = vec![plain_line_cell("Previous conversation")]; let mut tui = crate::tui::test_support::make_test_tui()?; tui.set_alt_screen_enabled(/*enabled*/ false); let screen_size = tui.terminal.last_known_screen_size; tui.insert_history_lines(vec![Line::from("Previous conversation")]); app.render_chat_widget_frame(&mut tui, screen_size)?; // Content-height popups keep the existing history without rebuilding scrollback. let popup_height = screen_size.height - 1; tui.draw(popup_height, |_| {})?; app.render_chat_widget_frame(&mut tui, screen_size)?; assert!(app.last_rendered_history_tail.is_none()); tui.enter_alt_screen()?; tui.draw(screen_size.height, |_| {})?; tui.leave_alt_screen()?; app.render_chat_widget_frame(&mut tui, screen_size)?; assert!(tui.terminal.viewport_area.height < screen_size.height); assert!(tui.terminal.viewport_area.y > 0); let tail = app .last_rendered_history_tail .as_ref() .expect("fullscreen exit rebuilt history"); assert_snapshot!( tail.lines .iter() .map(rendered_line_text) .collect::>() .join("\n"), @"Previous conversation" ); let deferred = vec![Line::from("Pending history").into()]; app.deferred_history_lines = deferred.clone(); app.render_chat_widget_frame(&mut tui, screen_size)?; assert_eq!(app.deferred_history_lines, deferred); Ok(()) } #[tokio::test] async fn copy_picker_opening_preserves_terminal_scrollback_without_reflow() { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; app.chat_widget.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal; let response = "Existing response\n\n```rust\nkeep_scrollback();\n```"; app.chat_widget.handle_server_notification( ServerNotification::ItemCompleted( codex_app_server_protocol::ItemCompletedNotification::new( String::new(), "turn-1".to_string(), serde_json::from_value(serde_json::json!({ "type": "agentMessage", "id": "message-1", "text": response, })) .expect("valid completed agent message"), /*started_at_ms*/ None, /*completed_at_ms*/ 0, ), ), /*replay_kind*/ None, ); while app_event_rx.try_recv().is_ok() {} app.transcript_cells = vec![ plain_line_cell("Older terminal scrollback"), Arc::new(AgentMarkdownCell::new( response.to_string(), Path::new("/tmp"), )), ]; app.deferred_history_lines = vec![Line::from("Buffered scrollback").into()]; app.has_emitted_history_lines = true; app.scrollback_has_older_history = true; app.transcript_reflow.note_width(/*width*/ 80); let before = app .render_transcript_lines_for_reflow(/*width*/ 80) .lines .iter() .map(rendered_line_text) .collect::>(); app.chat_widget.apply_external_edit("/copy".to_string()); assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Whole response")); app.chat_widget .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Whole response")); assert_eq!( app.render_transcript_lines_for_reflow(/*width*/ 80) .lines .iter() .map(rendered_line_text) .collect::>(), before ); assert_eq!(app.transcript_cells.len(), 2); assert_eq!(app.deferred_history_lines.len(), 1); assert!(app.has_emitted_history_lines); assert!(app.scrollback_has_older_history); assert!(!app.transcript_reflow.has_pending_reflow()); while let Ok(event) = app_event_rx.try_recv() { assert!( !matches!( event, AppEvent::ClearUi { .. } | AppEvent::ClearUiAndSubmitUserMessage { .. } | AppEvent::InsertHistoryCell(_) | AppEvent::ConsolidateAgentMessage { .. } ), "opening a picker must not modify terminal scrollback: {event:?}" ); } } #[tokio::test] async fn uncapped_resize_reflow_renders_all_cells_under_row_limit() { let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; app.local_settings.tui.terminal_resize_reflow_max_rows = Some(100); app.transcript_cells = (0..3) .map(|i| plain_line_cell(format!("cell {i}"))) .collect(); let rendered = app.render_transcript_lines_for_reflow(/*width*/ 80); assert_eq!( rendered .lines .iter() .map(rendered_line_text) .collect::>(), vec![ "cell 0".to_string(), String::new(), "cell 1".to_string(), String::new(), "cell 2".to_string(), ] ); } #[tokio::test] async fn initial_replay_buffer_keeps_recent_rows_when_row_cap_present() { let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; app.local_settings.tui.terminal_resize_reflow_max_rows = Some(3); app.begin_initial_history_replay_buffer(); for index in 0..5 { App::buffer_initial_history_replay_display_lines( app.initial_history_replay_buffer .as_mut() .expect("initial replay buffer active"), vec![Line::from(format!("line {index}")).into()], /*max_rows*/ 3, ); } let buffer = app .initial_history_replay_buffer .as_ref() .expect("initial replay buffer should remain active"); assert_eq!( buffer .retained_lines .iter() .map(rendered_line_text) .collect::>(), vec![ "line 2".to_string(), "line 3".to_string(), "line 4".to_string(), ] ); } #[tokio::test] async fn required_stream_reflow_during_capped_initial_replay_uses_transcript_tail() -> Result<()> { let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; app.local_settings.tui.terminal_resize_reflow_max_rows = Some(20); app.transcript_cells = vec![ plain_line_cell("latest user question"), Arc::new(AgentMarkdownCell::new( "Final answer:\n\n| Pattern | Outcome |\n| --- | --- |\n| Table tail | Preserved |" .to_string(), Path::new("/tmp"), )), ]; app.begin_initial_history_replay_buffer(); App::buffer_initial_history_replay_display_lines( app.initial_history_replay_buffer .as_mut() .expect("initial replay buffer active"), vec![Line::from("latest user question").into()], /*max_rows*/ 20, ); let mut tui = crate::tui::test_support::make_test_tui()?; app.finish_required_stream_reflow(&mut tui)?; let buffer = app .initial_history_replay_buffer .as_ref() .expect("initial replay buffer should remain active"); assert_eq!( ( buffer.retained_lines.len(), buffer.render_from_transcript_tail ), (0, true), ); let rendered = app.render_transcript_lines_for_reflow(/*width*/ 80); assert_snapshot!( "required_stream_reflow_during_capped_initial_replay", rendered .lines .iter() .map(rendered_line_text) .collect::>() .join("\n") ); app.finish_initial_history_replay_buffer(&mut tui); assert!(app.initial_history_replay_buffer.is_none()); assert!(app.transcript_reflow.has_pending_reflow()); Ok(()) } #[tokio::test] async fn directive_only_completion_removes_streamed_directive() -> Result<()> { let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; app.local_settings.tui.terminal_resize_reflow_max_rows = Some(20); app.begin_initial_history_replay_buffer(); app.transcript_cells = vec![ plain_line_cell("before directive"), Arc::new(AgentMessageCell::new( vec![Line::from(r#"::git-stage{cwd="/tmp"}"#)], /*is_first_line*/ true, )), ]; let mut tui = crate::tui::test_support::make_test_tui()?; app.handle_consolidate_agent_message( &mut tui, AgentMarkdownCell::new(String::new(), Path::new("/tmp")), ConsolidationScrollbackReflow::Required, /*deferred_history_cell*/ None, )?; let rendered = app.render_transcript_lines_for_reflow(/*width*/ 80); assert_snapshot!( "directive_only_completion_removes_streamed_directive", rendered .lines .iter() .map(rendered_line_text) .collect::>() .join("\n") ); Ok(()) } #[tokio::test] async fn required_stream_reflow_during_capped_initial_replay_survives_transcript_overlay() -> Result<()> { let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; app.local_settings.tui.terminal_resize_reflow_max_rows = Some(7); app.transcript_cells = vec![ plain_line_cell("latest user question"), Arc::new(AgentMessageCell::new( vec![Line::from("stale streamed table tail")], /*is_first_line*/ true, )), ]; app.begin_initial_history_replay_buffer(); App::buffer_initial_history_replay_display_lines( app.initial_history_replay_buffer .as_mut() .expect("initial replay buffer active"), vec![Line::from("stale streamed table tail").into()], /*max_rows*/ 7, ); let mut tui = crate::tui::test_support::make_test_tui()?; app.handle_consolidate_agent_message( &mut tui, AgentMarkdownCell::new( "Final answer:\n\n| Pattern | Outcome |\n| --- | --- |\n| Table tail | Preserved |" .into(), Path::new("/tmp"), ), ConsolidationScrollbackReflow::Required, /*deferred_history_cell*/ None, )?; app.open_transcript_overlay(&mut tui); assert!(tui.is_alt_screen_active()); app.finish_initial_history_replay_buffer(&mut tui); assert!(app.initial_history_replay_buffer.is_none()); assert!(app.transcript_reflow.has_pending_reflow()); let screen_size = tui.terminal.last_known_screen_size; app.maybe_run_resize_reflow(&mut tui, screen_size)?; assert!(app.transcript_reflow.has_pending_reflow()); app.close_transcript_overlay(&mut tui); assert!(!tui.is_alt_screen_active()); assert!(app.transcript_reflow.has_pending_reflow()); let rendered = app.render_transcript_lines_for_reflow(/*width*/ 80); assert_eq!(rendered.lines.len(), 7); let rendered = rendered .lines .iter() .map(rendered_line_text) .collect::>() .join("\n"); assert!(rendered.contains("Table tail")); assert!(rendered.contains("Preserved")); assert!(!rendered.contains("stale streamed table tail")); Ok(()) } #[tokio::test] async fn thread_switch_replay_buffer_uses_transcript_tail_mode_when_row_cap_present() { let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; app.local_settings.tui.terminal_resize_reflow_max_rows = Some(3); app.begin_thread_switch_history_replay_buffer(); let buffer = app .initial_history_replay_buffer .as_ref() .expect("thread switch replay buffer should be active"); assert!(buffer.render_from_transcript_tail); assert!(buffer.retained_lines.is_empty()); } #[tokio::test] async fn thread_switch_replay_buffer_is_disabled_without_row_cap() { let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; app.local_settings.tui.terminal_resize_reflow_max_rows = Some(0); app.begin_thread_switch_history_replay_buffer(); assert!(app.initial_history_replay_buffer.is_none()); } #[tokio::test] async fn height_shrink_schedules_resize_reflow() { let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; let frame_requester = crate::tui::FrameRequester::test_dummy(); assert!(!app.handle_draw_size_change( ratatui::layout::Size::new(/*width*/ 118, /*height*/ 35), ratatui::layout::Size::new(/*width*/ 118, /*height*/ 35), &frame_requester, )); assert!(app.handle_draw_size_change( ratatui::layout::Size::new(/*width*/ 118, /*height*/ 24), ratatui::layout::Size::new(/*width*/ 118, /*height*/ 35), &frame_requester, )); assert!(app.transcript_reflow.has_pending_reflow()); } #[tokio::test] async fn resizing_empty_transcript_schedules_settled_size_recheck() { let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; let mut tui = crate::tui::test_support::make_test_tui().expect("test tui"); let frame_requester = crate::tui::FrameRequester::test_dummy(); let initial_size = ratatui::layout::Size::new(/*width*/ 80, /*height*/ 24); let resized_size = ratatui::layout::Size::new(/*width*/ 100, /*height*/ 24); assert!(!app.handle_draw_size_change(initial_size, initial_size, &frame_requester)); tui.screen_size_for_event(&TuiEvent::Resize(resized_size)) .expect("resolve resize event"); tui.terminal.resize(resized_size).expect("apply event size"); assert!(app.handle_draw_size_change(resized_size, initial_size, &frame_requester)); tokio::time::sleep(crate::transcript_reflow::TRANSCRIPT_REFLOW_DEBOUNCE).await; assert_eq!( tui.screen_size_for_event(&TuiEvent::Draw) .expect("resolve settled size"), initial_size ); } fn test_turn(turn_id: &str, status: TurnStatus, items: Vec) -> Turn { Turn { id: turn_id.to_string(), root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: items.into_iter().map(ThreadItem::untimed).collect(), 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, cache_write_input_tokens: 0, output_tokens: 5, reasoning_output_tokens: 0, }, last: TokenUsageBreakdown { total_tokens: 10, input_tokens: 4, cached_input_tokens: 1, cache_write_input_tokens: 0, output_tokens: 5, reasoning_output_tokens: 0, }, model_context_window, }, }) } 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 { kind: Default::default(), thread_id: thread_id.to_string(), turn_id: turn_id.to_string(), item_id: item_id.to_string(), started_at_ms: 0, approval_id: approval_id.map(str::to_string), environment_id: None, reason: Some("needs approval".to_string()), network_approval_context: None, command: Some("echo hello".to_string()), cwd: Some(test_path_buf("/tmp/project").abs().into()), 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(), is_blocking: true, auto_resolution_ms: None, }, } } #[tokio::test] async fn feedback_submission_stages_logs_cleans_up_and_emits_error_history_cell() -> Result<()> { use base64::Engine; use base64::engine::general_purpose::STANDARD; use std::io::Write; use tracing_subscriber::fmt::writer::MakeWriter; let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; let (server, requests, proxy) = session_lifecycle_requests::start_recording_remote_app_server(&app.config).await?; let diagnostic = "SQLITE LOG WRITE FAILURE: disk full\n"; let expected_logs = format!("{}{diagnostic}", "x".repeat(1024 * 1024 - diagnostic.len())); let mut writer = app.feedback.make_writer().make_writer(); writer.write_all(&vec![b'x'; 1024 * 1024])?; writer.write_all(diagnostic.as_bytes())?; for (include_logs, staging_fails) in [(true, false), (false, false), (true, true)] { let home = tempdir()?; let tmp = home.path().join("tmp"); if staging_fails { std::fs::write(&tmp, "not a directory")?; } let mut params = background_requests::build_feedback_upload_params( /*origin_thread_id*/ None, Some(PathBuf::from("existing-rollout.jsonl")), FeedbackCategory::Bug, /*reason*/ None, /*turn_id*/ None, include_logs, ); // Reject before network upload, after the client has staged its diagnostics. params.thread_id = Some("invalid-thread-id".into()); let error = feedback_upload::fetch_feedback_upload( server.request_handle(), Some(AppServerPath::from_app_server( home.path().display().to_string(), )), params, app.feedback.clone(), ) .await .unwrap_err(); assert!(format!("{error:#}").contains("invalid thread id")); let recorded = std::mem::take(&mut *requests.lock().unwrap()); let upload = recorded .iter() .find(|r| r.method == "feedback/upload") .unwrap(); let upload: FeedbackUploadParams = serde_json::from_value(upload.params.clone().unwrap())?; if include_logs && !staging_fails { let write = recorded .iter() .find(|r| r.method == "fs/writeFile") .unwrap(); let write = write.params.as_ref().unwrap(); assert_eq!( STANDARD.decode(write["dataBase64"].as_str().unwrap())?, expected_logs.as_bytes() ); assert_eq!( upload.extra_log_files, Some(vec![ PathBuf::from("existing-rollout.jsonl"), PathBuf::from(write["path"].as_str().unwrap()) ]) ); assert_eq!(std::fs::read_dir(&tmp)?.count(), 0); } else if staging_fails { assert_eq!( upload.extra_log_files, Some(vec![PathBuf::from("existing-rollout.jsonl")]) ); assert!( upload .reason .unwrap() .contains("TUI client logs were omitted") ); } else { assert_eq!(recorded.len(), 1); assert_eq!(upload.extra_log_files, None); assert!(!tmp.exists()); } } server.shutdown().await?; proxy.await??; app.handle_feedback_submitted( /*origin_thread_id*/ None, FeedbackCategory::Bug, /*include_logs*/ true, Err("boom".to_string()), ) .await; let cell = match app_event_rx.try_recv() { Ok(AppEvent::InsertHistoryCell(cell)) => cell, other => panic!("expected feedback error history cell, saw {other:?}"), }; assert_eq!( lines_to_single_string(&cell.display_lines(/*width*/ 120)), "■ Failed to upload feedback: boom" ); Ok(()) } #[tokio::test] async fn feedback_submission_for_inactive_thread_replays_into_origin_thread() { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; let origin_thread_id = ThreadId::new(); let active_thread_id = ThreadId::new(); let origin_session = test_thread_session(origin_thread_id, test_path_buf("/tmp/origin")); let active_session = test_thread_session(active_thread_id, test_path_buf("/tmp/active")); app.thread_event_channels.insert( origin_thread_id, ThreadEventChannel::new_with_session( THREAD_EVENT_CHANNEL_CAPACITY, origin_session.clone(), Vec::new(), ), ); app.thread_event_channels.insert( active_thread_id, ThreadEventChannel::new_with_session( THREAD_EVENT_CHANNEL_CAPACITY, active_session.clone(), Vec::new(), ), ); app.activate_thread_channel(active_thread_id).await; app.chat_widget.handle_thread_session(active_session); while app_event_rx.try_recv().is_ok() {} app.handle_feedback_submitted( Some(origin_thread_id), FeedbackCategory::Bug, /*include_logs*/ true, Ok("uploaded-thread".to_string()), ) .await; assert_matches!( app_event_rx.try_recv(), Err(tokio::sync::mpsc::error::TryRecvError::Empty) ); let snapshot = { let channel = app .thread_event_channels .get(&origin_thread_id) .expect("origin thread channel should exist"); let store = channel.store.lock().await; assert!(matches!( store.buffer.back(), Some(ThreadBufferedEvent::FeedbackSubmission(_)) )); store.snapshot() }; 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!(rendered_cells.iter().any(|cell| { cell.contains("• Feedback uploaded. Please open an issue using the following URL:") && cell.contains("uploaded-thread") })); } fn next_history_message(events: &mut tokio::sync::mpsc::UnboundedReceiver) -> String { let cell = std::iter::from_fn(|| events.try_recv().ok()) .find_map(|event| match event { AppEvent::InsertHistoryCell(cell) => Some(cell), _ => None, }) .expect("history message"); lines_to_single_string(&cell.display_lines(/*width*/ 80)) } fn next_user_turn_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> Op { let mut seen = Vec::new(); while let Ok(op) = op_rx.try_recv() { if matches!(op, Op::UserTurn { .. }) { return op; } seen.push(format!("{op:?}")); } 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 = construct_model_info_offline_for_tests(model, &config.to_models_manager_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(), crate::test_support::session_source_cli(), ) } #[test] fn active_turn_not_steerable_turn_error_extracts_structured_server_error() { let turn_error = AppServerTurnError { misalignment: None, message: "cannot steer a review turn".to_string(), codex_error_info: Some(AppServerCodexErrorInfo::ActiveTurnNotSteerable { turn_kind: AppServerNonSteerableTurnKind::Review, }), additional_details: None, }; let error = TypedRequestError::Server { method: "turn/steer".to_string(), source: JSONRPCErrorError { code: -32602, message: turn_error.message.clone(), data: Some(serde_json::to_value(&turn_error).expect("turn error should serialize")), }, }; assert_eq!( active_turn_not_steerable_turn_error(&error), Some(turn_error) ); } #[test] fn active_turn_steer_race_detects_missing_active_turn() { let error = TypedRequestError::Server { method: "turn/steer".to_string(), source: JSONRPCErrorError { code: -32602, message: "no active turn to steer".to_string(), data: None, }, }; assert_eq!( active_turn_steer_race(&error), Some(ActiveTurnSteerRace::Missing) ); assert_eq!(active_turn_not_steerable_turn_error(&error), None); } #[test] fn active_turn_steer_race_extracts_actual_turn_id_from_mismatch() { let error = TypedRequestError::Server { method: "turn/steer".to_string(), source: JSONRPCErrorError { code: -32602, message: "expected active turn id `turn-expected` but found `turn-actual`".to_string(), data: None, }, }; assert_eq!( active_turn_steer_race(&error), Some(ActiveTurnSteerRace::ExpectedTurnMismatch { actual_turn_id: "turn-actual".to_string(), }) ); } #[test] fn active_turn_interrupt_race_extracts_actual_turn_id_from_mismatch() { let error = TypedRequestError::Server { method: "turn/interrupt".to_string(), source: JSONRPCErrorError { code: -32602, message: "expected active turn id turn-expected but found turn-actual".to_string(), data: None, }, }; assert_eq!( active_turn_interrupt_race(&error), Some("turn-actual".to_string()) ); } #[tokio::test] async fn backtrack_selection_preserves_selected_prompt_and_requests_branch() { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; let user_cell = |text: &str, text_elements: Vec, local_image_paths: Vec, remote_image_urls: Vec| -> Arc { Arc::new(UserHistoryCell { presentation: Default::default(), message: text.to_string(), text_elements, local_image_paths, remote_image_urls, }) as Arc }; let agent_cell = |text: &str| -> Arc { Arc::new(AgentMessageCell::new( vec![Line::from(text.to_string())], /*is_first_line*/ true, )) as Arc }; let make_header = |is_first| { let session = ThreadSessionState { daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id: ThreadId::new(), 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, 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: None, collaboration_mode: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), }; Arc::new(new_session_info( app.chat_widget.config_ref(), &app.local_settings, app.chat_widget.current_model(), &session.model, &session, is_first, /*tooltip_override*/ None, /*auth_plan*/ None, )) as Arc }; let placeholder = "[Image #1]"; let edited_text = format!("follow-up (edited) {placeholder}"); let edited_range = edited_text.len().saturating_sub(placeholder.len())..edited_text.len(); let edited_text_elements = vec![TextElement::new( edited_range.into(), /*placeholder*/ None, )]; let edited_local_image_paths = vec![PathBuf::from("/tmp/fake-image.png")]; // Simulate a transcript with duplicated history (e.g., from prior backtracks) // and an edited turn appended after a session header boundary. app.transcript_cells = vec![ make_header(true), user_cell("first question", Vec::new(), Vec::new(), Vec::new()), agent_cell("answer first"), user_cell("follow-up", Vec::new(), Vec::new(), Vec::new()), agent_cell("answer follow-up"), make_header(false), user_cell("first question", Vec::new(), Vec::new(), Vec::new()), agent_cell("answer first"), user_cell( &edited_text, edited_text_elements.clone(), edited_local_image_paths.clone(), vec!["https://example.com/backtrack.png".to_string()], ), agent_cell("answer edited"), ]; assert_eq!(user_count(&app.transcript_cells), 2); let transcript_before: Vec = app .transcript_cells .iter() .map(|cell| lines_to_single_string(&cell.display_lines(/*width*/ 80))) .collect(); let base_id = ThreadId::new(); app.chat_widget .handle_thread_session(crate::session_state::ThreadSessionState { daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id: base_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, 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: None, collaboration_mode: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), }); app.backtrack.base_id = Some(base_id); app.backtrack.primed = true; app.backtrack.nth_user_message = user_count(&app.transcript_cells).saturating_sub(1); let selection = app .confirm_backtrack_from_main() .expect("backtrack selection"); let expected = BacktrackSelection { thread_id: base_id, nth_user_message: 1, prompt: crate::chatwidget::UserMessage { text: edited_text, local_images: vec![crate::bottom_pane::LocalImageAttachment { placeholder: placeholder.to_string(), path: edited_local_image_paths[0].clone(), }], remote_image_urls: vec!["https://example.com/backtrack.png".to_string()], text_elements: edited_text_elements, mention_bindings: Vec::new(), }, }; assert_eq!(selection, expected); app.apply_backtrack_selection(selection); let event = std::iter::from_fn(|| app_event_rx.try_recv().ok()) .find(|event| matches!(event, AppEvent::RevertSessionForPromptEdit { .. })) .expect("prompt edit fork should be requested"); assert_matches!( event, AppEvent::RevertSessionForPromptEdit { thread_id, selected_cell, prompt, } if thread_id == expected.thread_id && Arc::ptr_eq(&selected_cell, &app.transcript_cells[nth_user_position(&app.transcript_cells, expected.nth_user_message).unwrap()]) && prompt == expected.prompt ); let transcript_after: Vec = app .transcript_cells .iter() .map(|cell| lines_to_single_string(&cell.display_lines(/*width*/ 80))) .collect(); assert_eq!(transcript_after, transcript_before); } #[tokio::test] async fn backtrack_branch_failure_restores_selected_prompt_snapshot() { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; app.restore_backtrack_prompt_after_revert_error( crate::chatwidget::UserMessage::from("edit this prompt"), "branch unavailable", ); assert_eq!( app.chat_widget.composer_text_with_pending(), "edit this prompt" ); let cell = match app_event_rx.try_recv() { Ok(AppEvent::InsertHistoryCell(cell)) => cell, other => panic!("expected InsertHistoryCell event, got {other:?}"), }; let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80)); assert_app_snapshot!( "backtrack_branch_failure_restores_selected_prompt", rendered ); } #[tokio::test] async fn remote_resume_current_cwd_rejection_snapshot() -> Result<()> { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; std::fs::write( app.config.codex_home.join("config.toml"), "[tui]\nresume_cwd = \"current\"\n", )?; app.app_server_target = crate::AppServerTarget::Remote { endpoint: crate::RemoteAppServerEndpoint::WebSocket { websocket_url: "ws://127.0.0.1:4500".to_string(), auth_token: None, }, }; let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; let backend_id = app_server.backend_id(); let mut tui = crate::tui::test_support::make_test_tui()?; let control = app .resume_target_session( &mut tui, &mut app_server, crate::resume_picker::SessionTarget { backend_id, path: None, thread_id: ThreadId::new(), cwd: None, history_mode: None, }, ) .await?; assert!(matches!(control, AppRunControl::Continue)); let cell = match app_event_rx.try_recv() { Ok(AppEvent::InsertHistoryCell(cell)) => cell, other => panic!("expected InsertHistoryCell event, got {other:?}"), }; let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80)); assert_app_snapshot!("remote_resume_current_cwd_rejected", rendered); app_server.shutdown().await?; Ok(()) } #[tokio::test] async fn remote_exec_resume_current_cwd_is_rejected() -> Result<()> { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; std::fs::write( app.config.codex_home.join("config.toml"), "[tui]\nresume_cwd = \"current\"\n", )?; app.environment_manager = Arc::new( EnvironmentManager::create_for_tests( Some("ws://127.0.0.1:8765".to_string()), Some(codex_exec_server::ExecServerRuntimeOptions::new( std::env::current_exe()?, /*codex_linux_sandbox_exe*/ None, )?), ) .await, ); let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; let backend_id = app_server.backend_id(); let mut tui = crate::tui::test_support::make_test_tui()?; let control = app .resume_target_session( &mut tui, &mut app_server, crate::resume_picker::SessionTarget { backend_id, path: None, thread_id: ThreadId::new(), cwd: None, history_mode: None, }, ) .await?; assert!(matches!(control, AppRunControl::Continue)); let cell = match app_event_rx.try_recv() { Ok(AppEvent::InsertHistoryCell(cell)) => cell, other => panic!("expected InsertHistoryCell event, got {other:?}"), }; let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80)); assert_eq!( rendered, "■ `tui.resume_cwd = \"current\"` requires `--cd` when using a remote workspace" ); app_server.shutdown().await?; Ok(()) } #[tokio::test] async fn in_app_resume_session_cwd_without_metadata_is_non_fatal() -> Result<()> { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; std::fs::write( app.config.codex_home.join("config.toml"), "[tui]\nresume_cwd = \"session\"\n", )?; app.state_db = None; let active_thread_id = app.chat_widget.thread_id(); app.chat_widget.insert_str("keep this draft"); let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; let backend_id = app_server.backend_id(); let mut tui = crate::tui::test_support::make_test_tui()?; let control = app .resume_target_session( &mut tui, &mut app_server, crate::resume_picker::SessionTarget { backend_id, path: None, thread_id: ThreadId::new(), cwd: None, history_mode: None, }, ) .await?; assert!(matches!(control, AppRunControl::Continue)); assert_eq!(app.chat_widget.thread_id(), active_thread_id); assert_eq!( app.chat_widget.composer_text_with_pending(), "keep this draft" ); let cell = match app_event_rx.try_recv() { Ok(AppEvent::InsertHistoryCell(cell)) => cell, other => panic!("expected InsertHistoryCell event, got {other:?}"), }; let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 100)); assert_app_snapshot!("in_app_resume_session_cwd_without_metadata", rendered); app_server.shutdown().await?; Ok(()) } #[tokio::test] async fn remote_resume_keeps_server_only_cwd_out_of_local_config() -> Result<()> { // Keep the large setup and resume futures off the Windows test stack. let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await; let local_cwd = app.config.cwd.to_path_buf(); let local_workspace_roots = Box::pin(app.rebuild_config_for_cwd(local_cwd.clone())) .await? .workspace_roots; let remote_cwd = if cfg!(windows) { PathBuf::from("/srv/remote/project") } else { PathBuf::from(r"C:\remote\project") }; let filename_timestamp = "2025-01-05T12-00-00"; let thread_id = app_test_support::create_fake_rollout( app.config.codex_home.as_path(), filename_timestamp, "2025-01-05T12:00:00Z", "Saved user message", Some(&app.config.model_provider_id), /*git_info*/ None, ) .expect("materialized rollout should be created"); let rollout_path = app_test_support::rollout_path( app.config.codex_home.as_path(), filename_timestamp, &thread_id, ); app.app_server_target = crate::AppServerTarget::Remote { endpoint: crate::RemoteAppServerEndpoint::WebSocket { websocket_url: "ws://127.0.0.1:4500".to_string(), auth_token: None, }, }; app.harness_overrides.cwd = Some(remote_cwd.clone()); let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; let backend_id = app_server.backend_id(); Box::pin(app_server.resume_thread( &crate::local_settings::LocalSettings::from(&app.config), app.config.clone(), ThreadId::from_string(&thread_id)?, crate::app_server_session::ResumeModelSettings::RestoreFromThread, )) .await?; let mut tui = crate::tui::test_support::make_test_tui()?; app.runtime_approval_policy_override = Some(RuntimeApprovalPolicyOverride::Restored( AskForApproval::Never, )); let mut prior_config = app.config.clone(); prior_config .permissions .set_permission_profile(PermissionProfile::read_only())?; app.runtime_permission_profile_override = Some( RuntimePermissionProfileOverride::from_restored_config(&prior_config), ); let control = Box::pin(app.resume_target_session( &mut tui, &mut app_server, crate::resume_picker::SessionTarget { backend_id, path: Some(rollout_path), thread_id: ThreadId::from_string(&thread_id)?, cwd: None, history_mode: None, }, )) .await?; assert!(matches!(control, AppRunControl::Continue)); assert_eq!(app.harness_overrides.cwd, Some(remote_cwd)); assert!(!crate::session_resume::cwds_differ( app.config.cwd.as_path(), &local_cwd, )); assert_eq!(app.config.workspace_roots, local_workspace_roots); assert_eq!(app.runtime_approval_policy_override, None); assert!(app.runtime_permission_profile_override.is_none()); app_server.shutdown().await?; Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn in_app_resume_uses_configured_or_explicit_cwd() -> Result<()> { let temp_dir = tempdir()?; let codex_home = temp_dir.path().join("codex-home"); let launch_cwd = temp_dir.path().join("launch"); let active_cwd = temp_dir.path().join("active"); let session_cwd = temp_dir.path().join("session"); let explicit_cwd = temp_dir.path().join("explicit"); let runtime_cwd = temp_dir.path().join("runtime"); for directory in [ &codex_home, &launch_cwd, &active_cwd, &session_cwd, &explicit_cwd, &runtime_cwd, ] { std::fs::create_dir_all(directory)?; } let mut local_app_server = None; let mut remote_app_server = None; for (configured_mode, has_explicit_cwd, has_remote_exec, has_runtime_cwd, expected_directory) in [ ("current", false, false, false, "launch"), ("session", false, false, false, "session"), ("session", true, false, false, "explicit"), ("session", false, true, false, "session"), ("session", true, true, false, "explicit"), ("current", false, false, true, "runtime"), ("current", true, false, true, "runtime"), ("session", false, false, true, "runtime"), ("session", true, false, true, "runtime"), ] { std::fs::write( codex_home.join("config.toml"), format!("[tui]\nresume_cwd = \"{configured_mode}\"\n"), )?; for cwd in [ &launch_cwd, &active_cwd, &session_cwd, &explicit_cwd, &runtime_cwd, ] { crate::legacy_core::config::set_project_trust_level( &codex_home, cwd, codex_protocol::config_types::TrustLevel::Trusted, ) .map_err(std::io::Error::other)?; } let config = ConfigBuilder::default() .codex_home(codex_home.clone()) .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) .harness_overrides(ConfigOverrides { cwd: Some(active_cwd.clone()), ..Default::default() }) .build() .await?; let filename_timestamp = "2025-01-05T12-00-00"; let thread_id = app_test_support::create_fake_rollout( &codex_home, filename_timestamp, "2025-01-05T12:00:00Z", "Saved user message", Some(&config.model_provider_id), /*git_info*/ None, ) .expect("materialized rollout should be created"); let rollout_path = app_test_support::rollout_path(&codex_home, filename_timestamp, &thread_id); let mut rollout_lines = std::fs::read_to_string(&rollout_path)? .lines() .map(serde_json::from_str::) .collect::, _>>()?; rollout_lines[0]["payload"]["cwd"] = serde_json::to_value(&session_cwd)?; std::fs::write( &rollout_path, format!( "{}\n", rollout_lines .into_iter() .map(|line| line.to_string()) .collect::>() .join("\n") ), )?; let thread_id = ThreadId::from_string(&thread_id)?; let environment_manager = if has_remote_exec { Arc::new( EnvironmentManager::create_for_tests( Some("ws://127.0.0.1:8765".to_string()), Some(codex_exec_server::ExecServerRuntimeOptions::new( std::env::current_exe()?, /*codex_linux_sandbox_exe*/ None, )?), ) .await, ) } else { Arc::new(EnvironmentManager::default_for_tests()) }; let server = if has_remote_exec { &mut remote_app_server } else { &mut local_app_server }; let app_server = match server { Some(server) => server, slot @ None => slot.insert( crate::start_app_server_for_picker( crate::backend_id::BackendId::new(), &config, &crate::AppServerTarget::Embedded, Vec::new(), LoaderOverrides::without_managed_config_for_tests(), /*state_db*/ None, Arc::clone(&environment_manager), ) .await?, ), }; let backend_id = app_server.backend_id(); let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; app.config = config; app.launch_cwd = launch_cwd.clone(); app.environment_manager = environment_manager; app.harness_overrides.cwd = has_explicit_cwd.then_some(explicit_cwd.clone()); app.runtime_working_directory_override = has_runtime_cwd.then_some(runtime_cwd.clone()); app.chat_widget .handle_thread_session_quiet(test_thread_session(ThreadId::new(), active_cwd.clone())); let mut tui = crate::tui::test_support::make_test_tui()?; let settings = app.backend_settings(); // Prepare a destination while another backend owns the displayed configuration. let other_home = tempdir()?; app.config.codex_home = other_home.path().to_path_buf().abs(); let active_settings = (app.config.clone(), app.local_settings.clone()); let active_thread_id = app.chat_widget.thread_id(); app.chat_widget .insert_str("keep this draft until attachment"); let prepared = match app .prepare_target_session( &mut tui, app_server, &crate::resume_picker::SessionTarget { backend_id, path: Some(rollout_path), thread_id, cwd: None, history_mode: None, }, super::resume_preparation::ResumeSource::Backend(&settings), ) .await { Ok(prepared) => prepared, Err(_) => panic!("saved thread should be ready for attachment"), }; assert_eq!( (&app.config, &app.local_settings), (&active_settings.0, &active_settings.1) ); assert_eq!(app.chat_widget.thread_id(), active_thread_id); assert_eq!( app.chat_widget.composer_text_with_pending(), "keep this draft until attachment" ); assert_eq!(app.chat_widget.config_ref().cwd.as_path(), active_cwd); let control = app .activate_prepared_session(&mut tui, app_server, prepared) .await?; assert!(matches!(control, AppRunControl::Continue)); let expected_cwd = temp_dir.path().join(expected_directory); assert!(!crate::session_resume::cwds_differ( app.config.cwd.as_path(), &expected_cwd, )); assert!(!crate::session_resume::cwds_differ( app.chat_widget.config_ref().cwd.as_path(), &expected_cwd, )); assert_eq!(app.chat_widget.thread_id(), Some(thread_id)); } if let Some(app_server) = local_app_server { app_server.shutdown().await?; } if let Some(app_server) = remote_app_server { app_server.shutdown().await?; } Ok(()) } #[tokio::test] async fn remembered_current_cwd_stays_at_launch_across_in_app_resumes() -> Result<()> { let temp_dir = tempdir()?; let codex_home = temp_dir.path().join("codex-home"); let launch_cwd = temp_dir.path().join("launch"); let active_cwd = temp_dir.path().join("active"); let first_session_cwd = temp_dir.path().join("first-session"); let second_session_cwd = temp_dir.path().join("second-session"); std::fs::create_dir_all(&codex_home)?; std::fs::create_dir_all(&launch_cwd)?; std::fs::create_dir_all(&active_cwd)?; std::fs::create_dir_all(&first_session_cwd)?; std::fs::create_dir_all(&second_session_cwd)?; crate::legacy_core::config::set_project_trust_level( &codex_home, &launch_cwd, codex_protocol::config_types::TrustLevel::Trusted, ) .map_err(std::io::Error::other)?; let config = ConfigBuilder::default() .codex_home(codex_home.clone()) .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) .harness_overrides(ConfigOverrides { cwd: Some(active_cwd.clone()), ..Default::default() }) .build() .await?; let immediately_selected_cwd = crate::cwd_prompt::CwdSelection::CurrentAndRemember .selected_cwd(&active_cwd, &first_session_cwd, &launch_cwd); assert!(!crate::session_resume::cwds_differ( immediately_selected_cwd, &launch_cwd, )); crate::legacy_core::config::edit::ConfigEditsBuilder::for_config(&config) .set_resume_cwd(codex_config::types::ResumeCwdMode::Current) .apply() .await .map_err(std::io::Error::other)?; let backend_id = crate::backend_id::BackendId::new(); let mut targets = Vec::new(); for (filename_timestamp, metadata_timestamp, session_cwd) in [ ( "2025-01-05T12-00-00", "2025-01-05T12:00:00Z", first_session_cwd, ), ( "2025-01-05T12-01-00", "2025-01-05T12:01:00Z", second_session_cwd, ), ] { let thread_id = app_test_support::create_fake_rollout( &codex_home, filename_timestamp, metadata_timestamp, "Saved user message", Some(&config.model_provider_id), /*git_info*/ None, ) .expect("materialized rollout should be created"); let rollout_path = app_test_support::rollout_path(&codex_home, filename_timestamp, &thread_id); let mut rollout_lines = std::fs::read_to_string(&rollout_path)? .lines() .map(serde_json::from_str::) .collect::, _>>()?; rollout_lines[0]["payload"]["cwd"] = serde_json::to_value(&session_cwd)?; std::fs::write( &rollout_path, format!( "{}\n", rollout_lines .into_iter() .map(|line| line.to_string()) .collect::>() .join("\n") ), )?; targets.push(crate::resume_picker::SessionTarget { backend_id, path: Some(rollout_path), thread_id: ThreadId::from_string(&thread_id)?, cwd: None, history_mode: None, }); } let state_db = crate::init_state_db_for_app_server_target(&config, &crate::AppServerTarget::Embedded) .await?; let mut app_server = Box::pin(crate::start_app_server_for_picker( backend_id, &config, &crate::AppServerTarget::Embedded, Vec::new(), LoaderOverrides::without_managed_config_for_tests(), state_db.clone(), Arc::new(EnvironmentManager::default_for_tests()), )) .await?; let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await; app.config = config; app.launch_cwd = launch_cwd.clone(); app.state_db = state_db; app.chat_widget .handle_thread_session_quiet(test_thread_session(ThreadId::new(), active_cwd)); let mut tui = crate::tui::test_support::make_test_tui()?; for target_session in targets { let control = Box::pin(app.resume_target_session(&mut tui, &mut app_server, target_session)).await?; assert!(matches!(control, AppRunControl::Continue)); assert!(!crate::session_resume::cwds_differ( app.config.cwd.as_path(), &launch_cwd, )); assert!(!crate::session_resume::cwds_differ( app.chat_widget.config_ref().cwd.as_path(), &launch_cwd, )); } app_server.shutdown().await?; Ok(()) } #[tokio::test] async fn prompt_edit_reverts_earlier_and_first_visible_prompts_in_place() -> Result<()> { // Allocate the whole scenario before Tokio polls it on the test thread. boxed_future!(prompt_edit_reverts_earlier_and_first_visible_prompts_in_place_scenario()).await } async fn prompt_edit_reverts_earlier_and_first_visible_prompts_in_place_scenario() -> Result<()> { use codex_protocol::items::TurnItem; use codex_protocol::items::UserMessageItem; use codex_protocol::models::ImageReference; use codex_protocol::protocol::ItemCompletedEvent; use codex_protocol::user_input::UserInput; let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; let config = app.chat_widget.config_ref().clone(); let filename_ts = "2025-01-05T12-00-00"; let source_thread_id = app_test_support::create_fake_paginated_rollout( config.codex_home.as_path(), filename_ts, "2025-01-05T12:00:00Z", "unused preview", Some(config.model_provider_id.as_str()), /*git_info*/ None, ) .expect("materialized rollout should be created"); let source_path = app_test_support::rollout_path(config.codex_home.as_path(), filename_ts, &source_thread_id); let session_meta = std::fs::read_to_string(&source_path)? .lines() .next() .expect("fake rollout should have session metadata") .to_string(); std::fs::write(&source_path, format!("{session_meta}\n"))?; for (turn_id, message, images, local_images) in [ ("turn-0", "older prompt", None, Vec::new()), ("turn-1", "retained prompt", None, Vec::new()), ( "turn-2", "selected prompt [Image #1]", Some(vec!["https://example.com/backtrack.png".to_string()]), vec![PathBuf::from("/tmp/fake-image.png")], ), ( "turn-3", "selected prompt [Image #1]", Some(vec!["https://example.com/backtrack.png".to_string()]), vec![PathBuf::from("/tmp/fake-image.png")], ), ] { let mut content = vec![UserInput::Text { text: message.to_string(), text_elements: Vec::new(), }]; content.extend( images .into_iter() .flatten() .map(|image_url| UserInput::Image { image: ImageReference::Inline { image_url }, detail: None, }), ); content.extend( local_images .into_iter() .map(|path| UserInput::LocalImage { path, detail: None }), ); for item in [ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_attribution: None, turn_id: turn_id.to_string(), root_turn_id: None, trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::default(), })), RolloutItem::EventMsg(EventMsg::ItemCompleted(ItemCompletedEvent { thread_id: ThreadId::from_string(&source_thread_id)?, turn_id: turn_id.to_string(), item: TurnItem::UserMessage(UserMessageItem { id: format!("user-{turn_id}"), client_id: None, content, }), started_at_ms: None, completed_at_ms: 0, })), RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { root_turn_id: None, turn_id: turn_id.to_string(), last_agent_message: None, error: None, started_at: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, })), ] { codex_rollout::append_rollout_item_to_path(&source_path, &item).await?; } } let source_thread_id = ThreadId::from_string(&source_thread_id)?; let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&config)).await?; // Bound fixture hydration independently from the owned viewport exercised below. // Inline hydration's row cap leaves the oldest prompt on an unloaded page. let mut local_settings = crate::local_settings::LocalSettings::from(&config); local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal; local_settings.tui.alternate_screen = codex_config::types::AltScreenMode::Never; local_settings.tui.terminal_resize_reflow_max_rows = Some(2); let started = app_server .resume_thread( &local_settings, config.clone(), source_thread_id, crate::app_server_session::ResumeModelSettings::OverrideFromCurrentConfig, ) .await?; assert!(app_server.has_older_history(source_thread_id)); let selected_turn = started.turns[3].clone(); app.enqueue_primary_thread_session(started.session, started.turns) .await?; app.scrollback_has_older_history = app_server.has_older_history(source_thread_id); { let mut store = app .thread_event_channels .get(&source_thread_id) .expect("source thread event channel") .store .lock() .await; // The selected prompt can outlive its bounded replay-buffer entries. store.turns.truncate(/*len*/ 2); store.push_notification(turn_started_notification( source_thread_id, &selected_turn.id, )); for item in selected_turn.items { store.push_notification(ServerNotification::ItemCompleted( codex_app_server_protocol::ItemCompletedNotification::new( source_thread_id.to_string(), selected_turn.id.clone(), item.payload, /*started_at_ms*/ None, /*completed_at_ms*/ 0, ), )); } store.push_notification(turn_completed_notification( source_thread_id, &selected_turn.id, TurnStatus::Interrupted, )); } while let Ok(event) = app_event_rx.try_recv() { if let AppEvent::InsertHistoryCell(cell) = event { app.transcript_cells.push(Arc::from(cell)); } } assert_eq!(crate::app_backtrack::user_count(&app.transcript_cells), 2); let child_id = ThreadId::new(); let child = Arc::clone(&app.ensure_thread_channel(child_id).store); let goal = app_server .thread_goal_set( source_thread_id, Some("Keep this goal".into()), Some(codex_app_server_protocol::ThreadGoalStatus::Paused), /*token_budget*/ None, ) .await? .goal; let mut tui = crate::tui::test_support::make_test_tui()?; tui.set_owned_screen(/*owned*/ true)?; let size = Size::new(/*width*/ 80, /*height*/ 24); app.render_owned_transcript(&mut tui, size)?; app.transcript_view .scroll(&app.transcript_cells, /*rows*/ -1); app.render_owned_transcript(&mut tui, size)?; // Freeze a visible selection before the same thread's authoritative history changes. let selected_row = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal) .content() .chunks(usize::from(size.width)) .position(|row| { row.iter() .map(ratatui::buffer::Cell::symbol) .collect::() .contains("selected prompt") }) .expect("selected prompt should be visible") as u16; for (kind, column) in [ ( crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left), 2, ), ( crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left), 8, ), ] { app.transcript_view.handle_mouse( crossterm::event::MouseEvent { kind, column, row: selected_row, modifiers: KeyModifiers::NONE, }, &app.transcript_cells, ); } assert!( app.transcript_view .selected_text(&app.transcript_cells) .is_some() ); let prompt = crate::chatwidget::UserMessage { text: "selected prompt [Image #1]".to_string(), local_images: vec![crate::bottom_pane::LocalImageAttachment { placeholder: "[Image #1]".to_string(), path: PathBuf::from("/tmp/fake-image.png"), }], remote_image_urls: vec!["https://example.com/backtrack.png".to_string()], text_elements: Vec::new(), mention_bindings: Vec::new(), }; Box::pin(app.handle_event( &mut tui, &mut app_server, AppEvent::UpdateModel("gpt-5.4".into()), )) .await?; app.app_server_target = crate::AppServerTarget::Remote { endpoint: crate::RemoteAppServerEndpoint::WebSocket { websocket_url: "ws://127.0.0.1:4500".to_string(), auth_token: None, }, }; let before = app_server .thread_read(source_thread_id, /*include_turns*/ true) .await?; for text in [None, Some("/help"), Some(" \t!")] { let mut remote_prompt = prompt.clone(); if let Some(text) = text { remote_prompt.text = text.into(); remote_prompt.local_images.clear(); } Box::pin(app.handle_event( &mut tui, &mut app_server, AppEvent::RevertSessionForPromptEdit { thread_id: source_thread_id, selected_cell: Arc::clone( &app.transcript_cells [nth_user_position(&app.transcript_cells, /*nth*/ 1).unwrap()], ), prompt: remote_prompt, }, )) .await?; assert!(app.chat_widget.composer_is_empty()); assert_eq!( app_server .thread_read(source_thread_id, /*include_turns*/ true) .await? .turns, before.turns ); } app.app_server_target = crate::AppServerTarget::Embedded; let selection = AppEvent::RevertSessionForPromptEdit { thread_id: source_thread_id, selected_cell: Arc::clone( &app.transcript_cells[nth_user_position(&app.transcript_cells, /*nth*/ 1).unwrap()], ), prompt: prompt.clone(), }; // A queued page shifts both identical prompts before confirmation is dispatched. let cursor = app_server .begin_older_history_page(source_thread_id) .expect("older page"); let page = app_server .thread_items_page( source_thread_id, /*turn_id*/ None, Some(cursor.clone()), /*limit*/ 1, ) .await?; app.handle_older_history_page( &mut tui, &mut app_server, source_thread_id, &cursor, Ok(page), ) .await?; let control = Box::pin(app.handle_event(&mut tui, &mut app_server, selection)).await?; while let Ok(event) = app_event_rx.try_recv() { match event { AppEvent::InsertHistoryCell(cell) => app.transcript_cells.push(Arc::from(cell)), event @ AppEvent::FinishPromptRevert { .. } => { Box::pin(app.handle_event(&mut tui, &mut app_server, event)).await?; } _ => {} } } assert_eq!( ( app.transcript_view.is_following(), app.transcript_view.selected_text(&app.transcript_cells) ), (true, None), ); let bottom = app.render_owned_transcript(&mut tui, size)?; let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); let transcript = buffer .content() .chunks(usize::from(size.width)) .take(usize::from(bottom.y)) .map(|row| { row.iter() .map(ratatui::buffer::Cell::symbol) .collect::() .trim_end() .to_string() }) .collect::>() .join("\n"); let stable_cwd = "/tmp/project"; let cwd = test_path_buf(stable_cwd).display().to_string(); let transcript = transcript.replace(&cwd, &format!("{stable_cwd:>(), vec!["turn-0", "turn-1", "turn-2"] ); let history = app .transcript_cells .iter() .map(|cell| lines_to_single_string(&cell.display_lines(/*width*/ 120))) .collect::>(); let retained_index = history .iter() .position(|line| line.contains("retained prompt")) .expect("revert should keep the earlier prompt"); let notice_index = history .iter() .position(|line| { line == "• Conversation reverted to this point. File changes are unchanged." }) .expect("prompt edit should emit the revert notice"); assert!(retained_index < notice_index); assert!( !history .iter() .any(|line| line.contains("Thread forked from")) ); // Editing the first visible prompt also discards search state from the removed window. app.transcript_view.begin_search(); assert!(app.transcript_view.is_search_editing()); app.chat_widget.restore_thread_input_state( /*input_state*/ None, crate::chatwidget::ThreadInputStateRestoreMode { preserve_in_flight_turn: false, }, ); Box::pin(app.handle_event( &mut tui, &mut app_server, AppEvent::RevertSessionForPromptEdit { thread_id: source_thread_id, selected_cell: Arc::clone( &app.transcript_cells[nth_user_position(&app.transcript_cells, /*nth*/ 0).unwrap()], ), prompt: "retained prompt".into(), }, )) .await?; while let Ok(event) = app_event_rx.try_recv() { if matches!(event, AppEvent::FinishPromptRevert { .. }) { Box::pin(app.handle_event(&mut tui, &mut app_server, event)).await?; } } assert!(!app.transcript_view.is_search_editing()); assert!(app.transcript_view.is_following()); assert_eq!(app.chat_widget.thread_id(), Some(source_thread_id)); assert_eq!( app.chat_widget.composer_text_with_pending(), "retained prompt" ); assert_eq!(crate::app_backtrack::user_count(&app.transcript_cells), 0); assert_eq!( app_server .thread_read(source_thread_id, /*include_turns*/ true) .await? .turns .iter() .map(|turn| turn.id.as_str()) .collect::>(), vec!["turn-0"] ); tui.set_owned_screen(/*owned*/ true)?; app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Draw) .await?; assert_eq!( app.transcript_view.history, crate::pager_overlay::TranscriptHistoryState::LoadingOlder ); loop { let event = app_event_rx.recv().await.expect("older history page"); if let AppEvent::OlderThreadHistoryLoaded { .. } = event { Box::pin(app.handle_event(&mut tui, &mut app_server, event)).await?; break; } } tui.set_owned_screen(/*owned*/ false)?; let prompts = app .transcript_cells .iter() .filter_map(|cell| { cell.as_any() .downcast_ref::() }) .map(|cell| cell.message.as_str()) .collect::>(); assert_eq!(prompts, vec!["older prompt"]); assert!(!app_server.has_older_history(source_thread_id)); assert_eq!( app_server.thread_goal_get(source_thread_id).await?.goal, Some(goal) ); tui.set_owned_screen(/*owned*/ false)?; app_server.shutdown().await?; Ok(()) } #[tokio::test] async fn replay_thread_snapshot_replays_turn_history_in_order() { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::new(); app.replay_thread_snapshot( ThreadEventSnapshot { delegated_turns: Vec::new(), session: Some(test_thread_session( thread_id, test_path_buf("/home/user/project"), )), turns: vec![ Turn { id: "turn-1".to_string(), root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![ThreadItem::untimed(ThreadItemPayload::UserMessage { id: "user-1".to_string(), client_id: None, content: vec![AppServerUserInput::Text { text: "first prompt".to_string(), text_elements: Vec::new(), }], })], status: TurnStatus::Completed, error: None, started_at: None, completed_at: None, duration_ms: None, }, Turn { id: "turn-2".to_string(), root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![ ThreadItem::untimed(ThreadItemPayload::UserMessage { id: "user-2".to_string(), client_id: None, content: vec![AppServerUserInput::Text { text: "third prompt".to_string(), text_elements: Vec::new(), }], }), ThreadItem::untimed(ThreadItemPayload::AgentMessage { id: "assistant-2".to_string(), text: "done".to_string(), phase: None, memory_citation: None, delivery: None, questions: None, }), ], status: TurnStatus::Completed, error: None, started_at: None, completed_at: None, duration_ms: None, }, ], events: Vec::new(), active_reasoning_item: None, input_state: None, }, /*resume_restored_queue*/ false, ); while let Ok(event) = app_event_rx.try_recv() { if let AppEvent::InsertHistoryCell(cell) = event { let cell: Arc = cell.into(); app.transcript_cells.push(cell); } } let user_messages: Vec = app .transcript_cells .iter() .filter_map(|cell| { cell.as_any() .downcast_ref::() .map(|cell| cell.message.clone()) }) .collect(); assert_eq!( user_messages, vec!["first prompt".to_string(), "third prompt".to_string()] ); } #[tokio::test] async fn replace_chat_widget_reseeds_collab_agent_metadata_for_replay() { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; let receiver_thread_id = ThreadId::from_string("019cff70-2599-75e2-af72-b958ce5dc1cc").expect("valid thread"); app.agent_navigation.upsert( receiver_thread_id, Some("Robie".to_string()), Some("explorer".to_string()), /*is_closed*/ false, ); let replacement = ChatWidget::new_with_app_event(ChatWidgetInit { requires_openai_auth: true, local_settings: crate::local_settings::LocalSettings::from(&app.config), config: app.config.clone(), frame_requester: crate::tui::FrameRequester::test_dummy(), app_event_tx: app.app_event_tx.clone(), workspace_command_runner: None, initial_user_message: None, enhanced_keys_supported: app.enhanced_keys_supported, has_chatgpt_account: app.chat_widget.has_chatgpt_account(), has_codex_backend_auth: app.chat_widget.has_codex_backend_auth(), model_catalog: app.model_catalog.clone(), feedback: app.feedback.clone(), is_first_run: false, status_account_display: app.chat_widget.status_account_display().cloned(), initial_plan_type: app.chat_widget.current_plan_type(), model: Some(app.chat_widget.current_model().to_string()), startup_tooltip_override: None, status_line_invalid_items_warned: app.status_line_invalid_items_warned.clone(), terminal_title_invalid_items_warned: app.terminal_title_invalid_items_warned.clone(), session_telemetry: app.session_telemetry.clone(), }); app.replace_chat_widget(replacement); app.replay_thread_snapshot( ThreadEventSnapshot { delegated_turns: Vec::new(), session: None, turns: Vec::new(), events: vec![ThreadBufferedEvent::Notification(Box::new( ServerNotification::ItemStarted( codex_app_server_protocol::ItemStartedNotification::new( "thread-1".to_string(), "turn-1".to_string(), ThreadItemPayload::CollabAgentToolCall { id: "wait-1".to_string(), tool: codex_app_server_protocol::CollabAgentTool::Wait, status: codex_app_server_protocol::CollabAgentToolCallStatus::InProgress, sender_thread_id: ThreadId::new().to_string(), receiver_thread_ids: vec![receiver_thread_id.to_string()], prompt: None, model: None, reasoning_effort: None, agents_states: HashMap::new(), }, /*started_at_ms*/ 0, ), ), ))], active_reasoning_item: None, input_state: None, }, /*resume_restored_queue*/ false, ); let mut saw_named_wait = false; while let Ok(event) = app_event_rx.try_recv() { if let AppEvent::InsertHistoryCell(cell) = event { let transcript = lines_to_single_string(&cell.transcript_lines(/*width*/ 80)); saw_named_wait |= transcript.contains("Robie [explorer]"); } } assert!( saw_named_wait, "expected replayed wait item to keep agent name" ); } #[tokio::test] async fn refreshed_snapshot_session_persists_resumed_turns() { let mut app = make_test_app().await; let thread_id = ThreadId::new(); let initial_session = test_thread_session(thread_id, test_path_buf("/tmp/original")); app.thread_event_channels.insert( thread_id, ThreadEventChannel::new_with_session( /*capacity*/ 4, initial_session.clone(), Vec::new(), ), ); let resumed_turns = vec![test_turn( "turn-1", TurnStatus::Completed, vec![ThreadItemPayload::UserMessage { id: "user-1".to_string(), client_id: None, content: vec![AppServerUserInput::Text { text: "restored prompt".to_string(), text_elements: Vec::new(), }], }], )]; let resumed_session = ThreadSessionState { cwd: test_path_buf("/tmp/refreshed").abs(), runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), ..initial_session.clone() }; let mut snapshot = ThreadEventSnapshot { delegated_turns: Vec::new(), session: Some(initial_session), turns: Vec::new(), events: Vec::new(), active_reasoning_item: None, input_state: None, }; app.apply_refreshed_snapshot_thread( thread_id, AppServerStartedThread { session: resumed_session.clone(), turns: resumed_turns.clone(), blocks_direct_input: true, task_tools_available: false, }, &mut snapshot, ) .await; assert!(app.agent_navigation.is_parent_owned(thread_id)); assert_eq!(snapshot.session, Some(resumed_session.clone())); assert_eq!(snapshot.turns, resumed_turns); let store = app .thread_event_channels .get(&thread_id) .expect("thread channel") .store .lock() .await; let store_snapshot = store.snapshot(); assert_eq!(store_snapshot.session, Some(resumed_session)); assert_eq!(store_snapshot.turns, snapshot.turns); assert_eq!( store.recap_progress(), recap::RecapProgress { completed_turns: 1, last_recapped_turn_count: None, } ); } #[tokio::test] async fn new_session_requests_shutdown_for_previous_conversation() { Box::pin(async { let (mut app, mut app_event_rx, mut op_rx) = Box::pin(make_test_app_with_channels()).await; let thread_id = ThreadId::new(); let event = crate::session_state::ThreadSessionState { daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, 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, 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: None, collaboration_mode: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), }; app.chat_widget.handle_thread_session(event); while app_event_rx.try_recv().is_ok() {} while op_rx.try_recv().is_ok() {} let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( app.chat_widget.config_ref(), )) .await .expect("embedded app server"); Box::pin(app.shutdown_current_thread(&mut app_server)).await; assert!( op_rx.try_recv().is_err(), "shutdown should not submit Op::Shutdown" ); }) .await; } #[tokio::test] async fn shutdown_first_exit_returns_immediate_exit_when_shutdown_submit_fails() { let mut app = make_test_app().await; let thread_id = ThreadId::new(); app.active_thread_id = Some(thread_id); let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( app.chat_widget.config_ref(), )) .await .expect("embedded app server"); let control = Box::pin(app.handle_exit_mode(&mut app_server, ExitMode::ShutdownFirst)).await; assert_eq!(app.pending_shutdown_exit_thread_id, None); assert!(matches!( control, AppRunControl::Exit(ExitReason::UserRequested) )); } #[tokio::test] async fn shutdown_first_exit_uses_app_server_shutdown_without_submitting_op() { let (mut app, _app_event_rx, mut op_rx) = Box::pin(make_test_app_with_channels()).await; let thread_id = ThreadId::new(); app.active_thread_id = Some(thread_id); let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( app.chat_widget.config_ref(), )) .await .expect("embedded app server"); let control = Box::pin(app.handle_exit_mode(&mut app_server, ExitMode::ShutdownFirst)).await; assert_eq!(app.pending_shutdown_exit_thread_id, None); assert!(matches!( control, AppRunControl::Exit(ExitReason::UserRequested) )); assert!( op_rx.try_recv().is_err(), "shutdown should not submit Op::Shutdown" ); } #[tokio::test] async fn interrupt_without_active_turn_is_treated_as_handled() { Box::pin(async { let mut app = make_test_app().await; let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( app.chat_widget.config_ref(), )) .await .expect("embedded app server"); let started = app_server .start_thread(app.chat_widget.config_ref()) .await .expect("thread/start should succeed"); let thread_id = started.session.thread_id; app.enqueue_primary_thread_session(started.session, started.turns) .await .expect("primary thread should be registered"); app.backtrack.primed = true; let op = AppCommand::interrupt(); let handled = Box::pin(app.try_submit_active_thread_op_via_app_server( &mut app_server, thread_id, &op, )) .await .expect("interrupt submission should not fail"); assert_eq!(handled, true); assert!(!app.backtrack.primed); }) .await; } #[tokio::test] async fn override_turn_context_sends_thread_settings_update() { Box::pin(async { let mut app = make_test_app().await; let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) .await .expect("embedded app server"); let started = app_server .start_thread(app.chat_widget.config_ref()) .await .expect("thread/start should succeed"); let thread_id = started.session.thread_id; let initial_model = started.session.model.clone(); let initial_effort = started.session.reasoning_effort.clone(); app.enqueue_primary_thread_session(started.session, started.turns) .await .expect("primary thread should be registered"); let service_tier = ServiceTier::Fast.request_value(); let collaboration_mode = CollaborationMode { mode: ModeKind::Plan, settings: Settings { model: "gpt-5.4".to_string(), reasoning_effort: Some(ReasoningEffortConfig::High), developer_instructions: None, }, }; let op = AppCommand::override_turn_context( /*cwd*/ None, Some(AskForApproval::OnRequest), Some(ApprovalsReviewer::AutoReview), /*permission_profile*/ None, Some(ActivePermissionProfile::new( codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE, )), Some("gpt-5.4".to_string()), Some(Some(ReasoningEffortConfig::High)), /*summary*/ None, Some(Some(service_tier.to_string())), Some(collaboration_mode.clone()), ); let handled = app .try_submit_active_thread_op_via_app_server(&mut app_server, thread_id, &op) .await .expect("settings update submission should not fail"); assert_eq!(handled, true); assert_eq!( app.primary_session_configured .as_ref() .expect("primary session") .model, initial_model, "thread/settings/update response is only an ack; cached state changes on notification" ); let notification = next_thread_settings_updated(&mut app_server, thread_id).await; assert_eq!(notification.thread_settings.model, "gpt-5.4"); assert_eq!( notification.thread_settings.effort, Some(ReasoningEffortConfig::High) ); assert_eq!( notification.thread_settings.service_tier.as_deref(), Some(service_tier) ); assert_eq!( notification.thread_settings.approval_policy, AskForApproval::OnRequest ); assert_eq!( notification.thread_settings.approvals_reviewer.to_core(), ApprovalsReviewer::AutoReview ); let notified_mode = ¬ification.thread_settings.collaboration_mode; assert_eq!(notified_mode.mode, collaboration_mode.mode); assert_eq!( notified_mode.settings.model, collaboration_mode.settings.model ); assert_eq!( notified_mode.settings.reasoning_effort, collaboration_mode.settings.reasoning_effort ); app.handle_app_server_event( &app_server, codex_app_server_client::AppServerEvent::ServerNotification(Box::new( ServerNotification::ThreadSettingsUpdated(notification), )), ) .await; let updated_session = app .primary_session_configured .as_ref() .expect("primary session should be updated from notification"); assert_eq!(updated_session.model, initial_model); assert_eq!(updated_session.reasoning_effort, initial_effort); let updated_mode = updated_session .collaboration_mode .as_deref() .expect("collaboration mode should be cached"); assert_eq!(updated_mode.mode, collaboration_mode.mode); assert_eq!( updated_mode.settings.model, collaboration_mode.settings.model ); assert_eq!( updated_mode.settings.reasoning_effort, collaboration_mode.settings.reasoning_effort ); assert_eq!(updated_session.service_tier.as_deref(), Some(service_tier)); assert_eq!(updated_session.approval_policy, AskForApproval::OnRequest); assert_eq!( updated_session.approvals_reviewer, ApprovalsReviewer::AutoReview ); assert_eq!( updated_session .active_permission_profile .as_ref() .expect("active profile") .id, codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE ); }) .await; } #[tokio::test] async fn selecting_cyber_model_defaults_active_thread_to_auto_review() { Box::pin(async { let (mut app, mut events, _) = make_test_app_with_channels().await; app.config .permissions .approval_policy .set(AskForApproval::UnlessTrusted.to_core()) .expect("set approval policy"); app.chat_widget .set_approval_policy(AskForApproval::UnlessTrusted); let mut model = app .model_catalog .try_list_models() .expect("model catalog") .into_iter() .find(|model| model.model == "gpt-5.5") .expect("gpt-5.5 model"); model.model_specialty = Some(MODEL_SPECIALTY_CYBER.to_string()); app.model_catalog = Arc::new(ModelCatalog::new(vec![model])); let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) .await .expect("embedded app server"); let started = app_server .start_thread(app.chat_widget.config_ref()) .await .expect("thread/start should succeed"); assert_eq!( started.session.approval_policy, AskForApproval::UnlessTrusted ); assert_eq!(started.session.approvals_reviewer, ApprovalsReviewer::User); let thread_id = started.session.thread_id; app.chat_widget .handle_thread_session_quiet(started.session.clone()); app.enqueue_primary_thread_session(started.session, started.turns) .await .expect("primary thread should be registered"); let mut tui = crate::tui::test_support::make_test_tui().expect("test tui"); let previous_model = app.chat_widget.current_model().to_string(); app.pending_server_profiles.insert( thread_id, PermissionProfileSelection { profile_id: "server-only".into(), approval_policy: None, approvals_reviewer: None, display_label: "server-only".into(), }, ); app.handle_event( &mut tui, &mut app_server, AppEvent::ApplyAdvancedReasoning { model: "gpt-5.5".to_string(), effort: ReasoningEffortConfig::High, }, ) .await .expect("pending permission selection should block the cyber model"); assert_eq!(app.chat_widget.current_model(), previous_model); let previous_selection = app.pending_server_profiles.remove(&thread_id).unwrap(); app.agents_overview .requested_permission_profiles .insert(thread_id, previous_selection); app.handle_event( &mut tui, &mut app_server, AppEvent::UpdateModel("gpt-5.5".to_string()), ) .await .expect("model selection should succeed"); assert!(app.pending_server_profiles.contains_key(&thread_id)); assert!( !app.agents_overview .requested_permission_profiles .contains_key(&thread_id) ); while events.try_recv().is_ok() {} // Until the cyber model's permissions are confirmed, do not offer another selection. app.chat_widget.handle_paste("/permissions".into()); app.chat_widget.handle_key_event(KeyCode::Enter.into()); let event = std::iter::from_fn(|| events.try_recv().ok()) .find(|event| matches!(event, AppEvent::OpenPermissionsPopup)) .expect("slash permissions goes through app permission guards"); app.handle_event(&mut tui, &mut app_server, event) .await .expect("permissions menu"); insta::assert_snapshot!( next_history_message(&mut events), @"■ Wait for permissions to update before changing permissions." ); let notification = next_thread_settings_updated(&mut app_server, thread_id).await; assert_eq!( notification.thread_settings.approval_policy, AskForApproval::OnRequest ); assert_eq!( notification.thread_settings.approvals_reviewer.to_core(), ApprovalsReviewer::AutoReview ); assert_eq!( notification .thread_settings .active_permission_profile .as_ref() .expect("active permission profile") .id, codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE ); app.enqueue_thread_notification( thread_id, ServerNotification::ThreadSettingsUpdated(notification), ) .await .expect("settings notification should apply"); assert!(!app.pending_server_profiles.contains_key(&thread_id)); }) .await; } #[tokio::test] async fn changing_cyber_model_reasoning_preserves_selected_permissions() { Box::pin(async { let mut app = make_test_app().await; let model_name = app.chat_widget.current_model().to_string(); let mut model = app .model_catalog .try_list_models() .expect("model catalog") .into_iter() .find(|model| model.model == model_name) .expect("current model"); model.model_specialty = Some(MODEL_SPECIALTY_CYBER.to_string()); app.model_catalog = Arc::new(ModelCatalog::new(vec![model])); assert!( app.apply_permission_profile_selection(PermissionProfileSelection { profile_id: codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY .to_string(), approval_policy: Some(AskForApproval::OnRequest), approvals_reviewer: Some(ApprovalsReviewer::User), display_label: "Read Only".to_string(), }) .await ); let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) .await .expect("embedded app server"); let started = app_server .start_thread(app.chat_widget.config_ref()) .await .expect("thread/start should succeed"); let thread_id = started.session.thread_id; app.enqueue_primary_thread_session(started.session, started.turns) .await .expect("primary thread should be registered"); let mut tui = crate::tui::test_support::make_test_tui().expect("test tui"); for (effort, session_only) in [ (ReasoningEffortConfig::High, false), (ReasoningEffortConfig::Medium, true), (ReasoningEffortConfig::Ultra, false), (ReasoningEffortConfig::High, true), (ReasoningEffortConfig::Ultra, true), ] { app.chat_widget.set_model(&model_name); if session_only { app.chat_widget.set_model("different-base-model"); let default_mode = app.chat_widget.current_collaboration_mode().clone(); app.chat_widget .set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true); app.chat_widget .set_collaboration_mask(CollaborationModeMask { name: "Plan".to_string(), mode: Some(ModeKind::Plan), model: Some(model_name.clone()), reasoning_effort: Some(Some(effort.clone())), developer_instructions: None, }); app.handle_event( &mut tui, &mut app_server, AppEvent::SelectSessionModel { model: model_name.clone(), effort: Some(effort.clone()), service_tier: None, }, ) .await .expect("session-only reasoning selection should succeed"); assert_eq!(app.chat_widget.current_collaboration_mode(), &default_mode); } else if effort == ReasoningEffortConfig::Ultra { app.chat_widget .set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true); app.chat_widget .set_collaboration_mask(CollaborationModeMask { name: "Plan".to_string(), mode: Some(ModeKind::Plan), model: Some(model_name.clone()), reasoning_effort: Some(Some(effort.clone())), developer_instructions: None, }); app.handle_event( &mut tui, &mut app_server, AppEvent::ApplyAdvancedReasoning { model: model_name.clone(), effort: effort.clone(), }, ) .await .expect("advanced reasoning selection should succeed"); } else { app.handle_event( &mut tui, &mut app_server, AppEvent::UpdateModel(model_name.clone()), ) .await .expect("same-model selection should succeed"); app.handle_event( &mut tui, &mut app_server, AppEvent::UpdateReasoningEffort(Some(effort.clone())), ) .await .expect("reasoning selection should succeed"); } let settings = next_thread_settings_updated(&mut app_server, thread_id) .await .thread_settings; assert_eq!(settings.effort, Some(effort)); assert_eq!(settings.approval_policy, AskForApproval::OnRequest); assert_eq!( settings.approvals_reviewer.to_core(), ApprovalsReviewer::User ); assert_eq!( settings .active_permission_profile .expect("active permission profile") .id, codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY ); assert_eq!( settings.collaboration_mode.mode, app.chat_widget.effective_collaboration_mode().mode ); assert_eq!(settings.collaboration_mode.settings.model, model_name); } }) .await; } #[tokio::test] async fn selecting_cyber_model_falls_back_to_user_when_auto_review_is_unavailable() { let mut app = make_test_app().await; let mut model = app .model_catalog .try_list_models() .expect("model catalog") .into_iter() .find(|model| model.model == "gpt-5.5") .expect("gpt-5.5 model"); model.model_specialty = Some(MODEL_SPECIALTY_CYBER.to_string()); app.model_catalog = Arc::new(ModelCatalog::new(vec![model])); let _ = app.config.features.disable(Feature::GuardianApproval); app.chat_widget .set_feature_enabled(Feature::GuardianApproval, /*enabled*/ false); app.active_thread_id = Some(ThreadId::new()); let params = app .active_thread_model_setting_update_params("gpt-5.5".to_string()) .expect("active thread should produce update params"); assert_eq!( params.approval_policy, Some(codex_app_server_protocol::AskForApproval::OnRequest) ); assert_eq!( params.approvals_reviewer, Some(codex_app_server_protocol::ApprovalsReviewer::User) ); } #[tokio::test] async fn selecting_cyber_model_respects_auto_review_requirements() { Box::pin(async { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; let requirements_toml = codex_config::ConfigRequirementsToml { allowed_approval_policies: Some(vec![AskForApproval::UnlessTrusted.to_core()]), ..Default::default() }; let mut requirements_with_sources = codex_config::ConfigRequirementsWithSources::default(); requirements_with_sources.merge_unset_fields( codex_config::RequirementSource::Unknown, requirements_toml.clone(), ); let requirements = codex_config::ConfigRequirements::try_from(requirements_with_sources) .expect("reviewer requirements"); app.config.config_layer_stack = codex_config::ConfigLayerStack::new(Vec::new(), requirements, requirements_toml) .expect("auto-review requirements stack"); app.config .permissions .approval_policy .set(AskForApproval::UnlessTrusted.to_core()) .expect("set approval policy"); app.chat_widget .set_approval_policy(AskForApproval::UnlessTrusted); app.chat_widget.sync_plugin_mentions_config(&app.config); let mut model = app .model_catalog .try_list_models() .expect("model catalog") .into_iter() .find(|model| model.model == "gpt-5.5") .expect("gpt-5.5 model"); model.model_specialty = Some(MODEL_SPECIALTY_CYBER.to_string()); app.model_catalog = Arc::new(ModelCatalog::new(vec![model])); let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) .await .expect("embedded app server"); let started = app_server .start_thread(app.chat_widget.config_ref()) .await .expect("thread/start should succeed"); assert_eq!( started.session.approval_policy, AskForApproval::UnlessTrusted ); let thread_id = started.session.thread_id; app.enqueue_primary_thread_session(started.session, started.turns) .await .expect("primary thread should be registered"); let mut tui = crate::tui::test_support::make_test_tui().expect("test tui"); app.handle_event( &mut tui, &mut app_server, AppEvent::UpdateModel("gpt-5.5".to_string()), ) .await .expect("model selection should succeed"); let notification = next_thread_settings_updated(&mut app_server, thread_id).await; assert_eq!( notification.thread_settings.approval_policy, AskForApproval::UnlessTrusted ); assert_eq!( notification.thread_settings.approvals_reviewer.to_core(), ApprovalsReviewer::User ); assert!( std::iter::from_fn(|| app_event_rx.try_recv().ok()) .all(|event| !matches!(event, AppEvent::CyberModelAutoReviewNotice)) ); }) .await; } #[tokio::test] async fn thread_setting_update_params_sync_model_and_default_reasoning() { let mut app = make_test_app().await; let thread_id = ThreadId::new(); app.active_thread_id = Some(thread_id); app.chat_widget.set_model("gpt-5.4"); let params = app .active_thread_model_setting_update_params("gpt-5.4".to_string()) .expect("active thread should produce update params"); assert_eq!(params.thread_id, thread_id.to_string()); assert_eq!(params.model, Some("gpt-5.4".to_string())); assert_eq!( params .collaboration_mode .as_ref() .expect("collaboration mode should sync with model") .settings .model, "gpt-5.4" ); app.chat_widget .set_reasoning_effort(Some(ReasoningEffortConfig::Low)); app.chat_widget .set_collaboration_mask(CollaborationModeMask { name: "Plan".to_string(), mode: Some(ModeKind::Plan), model: Some("gpt-plan".to_string()), reasoning_effort: Some(Some(ReasoningEffortConfig::Medium)), developer_instructions: None, }); app.on_update_reasoning_effort(Some(ReasoningEffortConfig::High)); let params = app .active_thread_reasoning_setting_update_params(Some(ReasoningEffortConfig::High)) .expect("active thread should produce update params"); assert_eq!(params.thread_id, thread_id.to_string()); assert_eq!(params.effort, Some(ReasoningEffortConfig::High)); let collaboration_mode = params .collaboration_mode .expect("collaboration mode should sync with reasoning"); assert_eq!(collaboration_mode.mode, ModeKind::Default); assert_eq!( collaboration_mode.settings.reasoning_effort, Some(ReasoningEffortConfig::High) ); } #[tokio::test] async fn inactive_thread_settings_notification_updates_cached_collaboration_mode() { let mut app = make_test_app().await; let primary_thread_id = ThreadId::new(); let inactive_thread_id = ThreadId::new(); let primary_session = test_thread_session(primary_thread_id, test_path_buf("/tmp/main")); let inactive_session = test_thread_session(inactive_thread_id, test_path_buf("/tmp/inactive")); let collaboration_mode = CollaborationMode { mode: ModeKind::Plan, settings: Settings { model: "gpt-plan".to_string(), reasoning_effort: Some(ReasoningEffortConfig::High), developer_instructions: Some("draft a plan first".to_string()), }, }; app.primary_thread_id = Some(primary_thread_id); app.active_thread_id = Some(primary_thread_id); app.primary_session_configured = Some(primary_session.clone()); app.thread_event_channels.insert( primary_thread_id, ThreadEventChannel::new_with_session( THREAD_EVENT_CHANNEL_CAPACITY, primary_session, Vec::new(), ), ); app.thread_event_channels.insert( inactive_thread_id, ThreadEventChannel::new_with_session( THREAD_EVENT_CHANNEL_CAPACITY, inactive_session, Vec::new(), ), ); let notification = ThreadSettingsUpdatedNotification { thread_id: inactive_thread_id.to_string(), thread_settings: ThreadSettings { disabled_plugin_ids: Vec::new(), cwd: test_absolute_path("/tmp/thread-settings"), approval_policy: AskForApproval::OnRequest, approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::AutoReview, sandbox_policy: codex_app_server_protocol::SandboxPolicy::ReadOnly { network_access: false, }, active_permission_profile: Some( codex_app_server_protocol::ActivePermissionProfile::read_only(), ), model: "gpt-plan".to_string(), model_provider: "openai".to_string(), service_tier: None, effort: collaboration_mode.settings.reasoning_effort.clone(), summary: None, collaboration_mode: collaboration_mode.clone(), multi_agent_mode: Default::default(), personality: None, }, }; app.enqueue_thread_notification( inactive_thread_id, ServerNotification::ThreadSettingsUpdated(notification), ) .await .expect("settings notification should be cached"); let cached_session = app .thread_event_channels .get(&inactive_thread_id) .expect("inactive thread channel") .store .lock() .await .session .clone() .expect("inactive session should remain cached"); assert_eq!(cached_session.model, "gpt-test"); assert_eq!( cached_session.collaboration_mode.as_deref(), Some(&collaboration_mode) ); app.chat_widget.handle_thread_session(cached_session); assert_eq!( app.chat_widget.active_collaboration_mode_kind(), ModeKind::Plan ); assert_eq!(app.chat_widget.current_model(), "gpt-plan"); assert_eq!( app.chat_widget.current_collaboration_mode().model(), "gpt-test" ); assert_eq!( app.chat_widget.current_reasoning_effort(), Some(ReasoningEffortConfig::High) ); } #[tokio::test] async fn clear_only_ui_reset_preserves_chat_session_state() { let mut app = make_test_app().await; let thread_id = ThreadId::new(); app.chat_widget .handle_thread_session(crate::session_state::ThreadSessionState { daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, forked_from_id: None, fork_parent_title: None, thread_name: Some("keep me".to_string()), model: "gpt-test".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("/tmp/project").abs(), runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, collaboration_mode: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), }); app.chat_widget .apply_external_edit("draft prompt".to_string()); app.transcript_cells = vec![Arc::new(UserHistoryCell { presentation: Default::default(), message: "old message".to_string(), text_elements: Vec::new(), local_image_paths: Vec::new(), remote_image_urls: Vec::new(), }) as Arc]; app.overlay = Some(Overlay::new_transcript( app.transcript_cells.clone(), crate::keymap::RuntimeKeymap::defaults().pager, /*copy_on_select*/ false, /*mouse_scroll_speed*/ 1.0, )); app.deferred_history_lines = vec![Line::from("stale buffered line").into()]; app.has_emitted_history_lines = true; app.backtrack.primed = true; app.backtrack.overlay_preview_active = true; app.backtrack.nth_user_message = 0; app.backtrack_render_pending = true; app.reset_app_ui_state_after_clear(); assert!(app.overlay.is_none()); assert!(app.transcript_cells.is_empty()); assert!(app.deferred_history_lines.is_empty()); assert!(!app.has_emitted_history_lines); assert!(!app.backtrack.primed); assert!(!app.backtrack.overlay_preview_active); assert!(!app.backtrack_render_pending); assert_eq!(app.chat_widget.thread_id(), Some(thread_id)); assert_eq!(app.chat_widget.composer_text_with_pending(), "draft prompt"); } #[tokio::test] async fn clear_only_ui_reset_allows_active_skill_warning_to_render_again() { let mut app = make_test_app().await; let error = SkillErrorInfo { path: test_path_buf("/tmp/project/.codex/skills/abc/SKILL.md"), message: "invalid description".to_string(), }; assert_eq!( app.skill_load_warnings .newly_active_errors(std::slice::from_ref(&error)), vec![error.clone()] ); assert_eq!( app.skill_load_warnings .newly_active_errors(std::slice::from_ref(&error)), Vec::::new() ); app.reset_app_ui_state_after_clear(); assert_eq!( app.skill_load_warnings .newly_active_errors(std::slice::from_ref(&error)), vec![error] ); } #[tokio::test] async fn backtrack_esc_does_not_steal_empty_vim_insert_escape() { let mut app = make_test_app().await; let esc = crossterm::event::KeyEvent::new(crossterm::event::KeyCode::Esc, KeyModifiers::NONE); assert!(app.chat_widget.composer_is_empty()); assert!(app.should_handle_backtrack_esc(esc)); app.chat_widget.toggle_vim_mode_and_notify(); assert!(app.should_handle_backtrack_esc(esc)); app.chat_widget .handle_key_event(crossterm::event::KeyEvent::new( crossterm::event::KeyCode::Char('i'), KeyModifiers::NONE, )); assert!(app.chat_widget.should_handle_vim_insert_escape(esc)); assert!(!app.should_handle_backtrack_esc(esc)); app.chat_widget.handle_key_event(esc); assert!(!app.backtrack.primed); assert!(!app.chat_widget.should_handle_vim_insert_escape(esc)); assert!(app.should_handle_backtrack_esc(esc)); } #[tokio::test] async fn side_conversations_reject_backtrack_esc_without_stealing_vim_insert_escape() { let mut app = make_test_app().await; let esc = crossterm::event::KeyEvent::new(crossterm::event::KeyCode::Esc, KeyModifiers::NONE); app.chat_widget .set_side_conversation_active(/*active*/ true); assert!(app.chat_widget.composer_is_empty()); assert!(!app.should_handle_backtrack_esc(esc)); assert!(app.should_reject_side_backtrack_esc(esc)); app.chat_widget.toggle_vim_mode_and_notify(); app.chat_widget .handle_key_event(crossterm::event::KeyEvent::new( crossterm::event::KeyCode::Char('i'), KeyModifiers::NONE, )); assert!(app.chat_widget.should_handle_vim_insert_escape(esc)); assert!(!app.should_handle_backtrack_esc(esc)); assert!(!app.should_reject_side_backtrack_esc(esc)); } #[tokio::test] async fn side_backtrack_rejection_reports_unavailable_message_snapshot() { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; app.backtrack.primed = true; app.reject_side_backtrack_esc(); assert!(!app.backtrack.primed); let cell = match app_event_rx.try_recv() { Ok(AppEvent::InsertHistoryCell(cell)) => cell, other => panic!("expected InsertHistoryCell event, got {other:?}"), }; let rendered = cell .display_lines(/*width*/ 80) .into_iter() .map(|line| line.to_string()) .collect::>() .join("\n"); assert_app_snapshot!( "side_backtrack_rejection_reports_unavailable_message", rendered ); } async fn start_config_write_test_app_server(app: &App) -> Result { Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await } #[path = "tests/active_reconnect_tests.rs"] mod active_reconnect; #[cfg(unix)] #[path = "tests/navigation_reconnect_tests.rs"] mod navigation_reconnect; #[path = "tests/backend_event_tests.rs"] mod backend_event_tests;