diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 80e8f4a0f..f2dfd5fa2 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -200,8 +200,6 @@ mod thread_session_state; use self::agent_navigation::AgentNavigationDirection; use self::agent_navigation::AgentNavigationState; use self::app_server_requests::PendingAppServerRequests; -#[cfg(test)] -use self::background_requests::*; use self::loaded_threads::find_loaded_subagent_threads_for_primary; use self::pending_interactive_replay::PendingInteractiveReplayState; use self::platform_actions::*; @@ -1152,5 +1150,7 @@ impl Drop for App { } } +#[cfg(test)] +pub(super) mod test_support; #[cfg(test)] mod tests; diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 9fc741ff5..80879dbd7 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -637,3 +637,148 @@ pub(super) fn mcp_inventory_maps_from_statuses(statuses: Vec) - (tools, resources, resource_templates, auth_statuses) } + +#[cfg(test)] +mod tests { + use super::*; + use codex_app_server_protocol::PluginMarketplaceEntry; + use codex_protocol::mcp::Tool; + use codex_utils_absolute_path::AbsolutePathBuf; + use pretty_assertions::assert_eq; + + fn test_absolute_path(path: &str) -> AbsolutePathBuf { + AbsolutePathBuf::try_from(PathBuf::from(path)).expect("absolute test path") + } + + #[test] + fn hide_cli_only_plugin_marketplaces_removes_openai_bundled() { + let mut response = PluginListResponse { + marketplaces: vec![ + PluginMarketplaceEntry { + name: "openai-bundled".to_string(), + path: Some(test_absolute_path("/marketplaces/openai-bundled")), + interface: None, + plugins: Vec::new(), + }, + PluginMarketplaceEntry { + name: "openai-curated".to_string(), + path: Some(test_absolute_path("/marketplaces/openai-curated")), + interface: None, + plugins: Vec::new(), + }, + ], + marketplace_load_errors: Vec::new(), + featured_plugin_ids: Vec::new(), + }; + + hide_cli_only_plugin_marketplaces(&mut response); + + assert_eq!( + response.marketplaces, + vec![PluginMarketplaceEntry { + name: "openai-curated".to_string(), + path: Some(test_absolute_path("/marketplaces/openai-curated")), + interface: None, + plugins: Vec::new(), + }] + ); + } + + #[test] + fn mcp_inventory_maps_prefix_tool_names_by_server() { + let statuses = vec![ + McpServerStatus { + name: "docs".to_string(), + tools: HashMap::from([( + "list".to_string(), + Tool { + description: None, + name: "list".to_string(), + title: None, + input_schema: serde_json::json!({"type": "object"}), + output_schema: None, + annotations: None, + icons: None, + meta: None, + }, + )]), + resources: Vec::new(), + resource_templates: Vec::new(), + auth_status: codex_app_server_protocol::McpAuthStatus::Unsupported, + }, + McpServerStatus { + name: "disabled".to_string(), + tools: HashMap::new(), + resources: Vec::new(), + resource_templates: Vec::new(), + auth_status: codex_app_server_protocol::McpAuthStatus::Unsupported, + }, + ]; + + let (tools, resources, resource_templates, auth_statuses) = + mcp_inventory_maps_from_statuses(statuses); + let mut resource_names = resources.keys().cloned().collect::>(); + resource_names.sort(); + let mut template_names = resource_templates.keys().cloned().collect::>(); + template_names.sort(); + + assert_eq!( + tools.keys().cloned().collect::>(), + vec!["mcp__docs__list".to_string()] + ); + assert_eq!(resource_names, vec!["disabled", "docs"]); + assert_eq!(template_names, vec!["disabled", "docs"]); + assert_eq!( + auth_statuses.get("disabled"), + Some(&McpAuthStatus::Unsupported) + ); + } + + #[test] + fn build_feedback_upload_params_includes_thread_id_and_rollout_path() { + let thread_id = ThreadId::new(); + let rollout_path = PathBuf::from("/tmp/rollout.jsonl"); + + let params = build_feedback_upload_params( + Some(thread_id), + Some(rollout_path.clone()), + FeedbackCategory::SafetyCheck, + Some("needs follow-up".to_string()), + Some("turn-123".to_string()), + /*include_logs*/ true, + ); + + assert_eq!(params.classification, "safety_check"); + assert_eq!(params.reason, Some("needs follow-up".to_string())); + assert_eq!(params.thread_id, Some(thread_id.to_string())); + assert_eq!( + params + .tags + .as_ref() + .and_then(|tags| tags.get("turn_id")) + .map(String::as_str), + Some("turn-123") + ); + assert_eq!(params.include_logs, true); + assert_eq!(params.extra_log_files, Some(vec![rollout_path])); + } + + #[test] + fn build_feedback_upload_params_omits_rollout_path_without_logs() { + let params = build_feedback_upload_params( + /*origin_thread_id*/ None, + Some(PathBuf::from("/tmp/rollout.jsonl")), + FeedbackCategory::GoodResult, + /*reason*/ None, + /*turn_id*/ None, + /*include_logs*/ false, + ); + + assert_eq!(params.classification, "good_result"); + assert_eq!(params.reason, None); + assert_eq!(params.thread_id, None); + assert_eq!(params.tags, None); + assert_eq!(params.include_logs, false); + assert_eq!(params.extra_log_files, None); + } +} diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index c909a37c1..edfde7cee 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -537,3 +537,176 @@ impl App { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::test_support::app_enabled_in_effective_config; + use crate::app::test_support::make_test_app; + use crate::test_support::PathBufExt; + use codex_protocol::protocol::Event; + use codex_protocol::protocol::EventMsg; + use codex_protocol::protocol::SessionConfiguredEvent; + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + #[tokio::test] + async fn update_reasoning_effort_updates_collaboration_mode() { + let mut app = make_test_app().await; + app.chat_widget + .set_reasoning_effort(Some(ReasoningEffortConfig::Medium)); + + app.on_update_reasoning_effort(Some(ReasoningEffortConfig::High)); + + assert_eq!( + app.chat_widget.current_reasoning_effort(), + Some(ReasoningEffortConfig::High) + ); + assert_eq!( + app.config.model_reasoning_effort, + Some(ReasoningEffortConfig::High) + ); + } + + #[tokio::test] + async fn refresh_in_memory_config_from_disk_loads_latest_apps_state() -> Result<()> { + let mut app = make_test_app().await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + let app_id = "unit_test_refresh_in_memory_config_connector".to_string(); + + assert_eq!(app_enabled_in_effective_config(&app.config, &app_id), None); + + ConfigEditsBuilder::new(&app.config.codex_home) + .with_edits([ + ConfigEdit::SetPath { + segments: vec!["apps".to_string(), app_id.clone(), "enabled".to_string()], + value: false.into(), + }, + ConfigEdit::SetPath { + segments: vec![ + "apps".to_string(), + app_id.clone(), + "disabled_reason".to_string(), + ], + value: "user".into(), + }, + ]) + .apply() + .await + .expect("persist app toggle"); + + assert_eq!(app_enabled_in_effective_config(&app.config, &app_id), None); + + app.refresh_in_memory_config_from_disk().await?; + + assert_eq!( + app_enabled_in_effective_config(&app.config, &app_id), + Some(false) + ); + Ok(()) + } + + #[tokio::test] + async fn refresh_in_memory_config_from_disk_best_effort_keeps_current_config_on_error() + -> Result<()> { + let mut app = make_test_app().await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + std::fs::write(codex_home.path().join("config.toml"), "[broken")?; + let original_config = app.config.clone(); + + app.refresh_in_memory_config_from_disk_best_effort("starting a new thread") + .await; + + assert_eq!(app.config, original_config); + Ok(()) + } + + #[tokio::test] + async fn refresh_in_memory_config_from_disk_uses_active_chat_widget_cwd() -> Result<()> { + let mut app = make_test_app().await; + let original_cwd = app.config.cwd.clone(); + let next_cwd_tmp = tempdir()?; + let next_cwd = next_cwd_tmp.path().to_path_buf(); + + app.chat_widget.handle_codex_event(Event { + id: String::new(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: ThreadId::new(), + forked_from_id: None, + thread_name: None, + model: "gpt-test".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: next_cwd.clone().abs(), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: Some(PathBuf::new()), + }), + }); + + assert_eq!(app.chat_widget.config_ref().cwd.to_path_buf(), next_cwd); + assert_eq!(app.config.cwd, original_cwd); + + app.refresh_in_memory_config_from_disk().await?; + + assert_eq!(app.config.cwd, app.chat_widget.config_ref().cwd); + Ok(()) + } + + #[tokio::test] + async fn rebuild_config_for_resume_or_fallback_uses_current_config_on_same_cwd_error() + -> Result<()> { + let mut app = make_test_app().await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + std::fs::write(codex_home.path().join("config.toml"), "[broken")?; + let current_config = app.config.clone(); + let current_cwd = current_config.cwd.clone(); + + let resume_config = app + .rebuild_config_for_resume_or_fallback(¤t_cwd, current_cwd.to_path_buf()) + .await?; + + assert_eq!(resume_config, current_config); + Ok(()) + } + + #[tokio::test] + async fn rebuild_config_for_resume_or_fallback_errors_when_cwd_changes() -> Result<()> { + let mut app = make_test_app().await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + std::fs::write(codex_home.path().join("config.toml"), "[broken")?; + let current_cwd = app.config.cwd.clone(); + let next_cwd_tmp = tempdir()?; + let next_cwd = next_cwd_tmp.path().to_path_buf(); + + let result = app + .rebuild_config_for_resume_or_fallback(¤t_cwd, next_cwd) + .await; + + assert!(result.is_err()); + Ok(()) + } + + #[tokio::test] + async fn sync_tui_theme_selection_updates_chat_widget_config_copy() { + let mut app = make_test_app().await; + + app.sync_tui_theme_selection("dracula".to_string()); + + assert_eq!(app.config.tui_theme.as_deref(), Some("dracula")); + assert_eq!( + app.chat_widget.config_ref().tui_theme.as_deref(), + Some("dracula") + ); + } +} diff --git a/codex-rs/tui/src/app/platform_actions.rs b/codex-rs/tui/src/app/platform_actions.rs index 36559feb9..b8e288835 100644 --- a/codex-rs/tui/src/app/platform_actions.rs +++ b/codex-rs/tui/src/app/platform_actions.rs @@ -59,3 +59,38 @@ pub(super) fn side_return_shortcut_matches(key_event: KeyEvent) -> bool { _ => false, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn side_return_shortcuts_match_esc_and_ctrl_c() { + assert!(side_return_shortcut_matches(KeyEvent::new( + KeyCode::Esc, + KeyModifiers::NONE, + ))); + assert!(side_return_shortcut_matches(KeyEvent::new_with_kind( + KeyCode::Esc, + KeyModifiers::NONE, + KeyEventKind::Repeat, + ))); + assert!(side_return_shortcut_matches(KeyEvent::new( + KeyCode::Char('c'), + KeyModifiers::CONTROL, + ))); + assert!(side_return_shortcut_matches(KeyEvent::new( + KeyCode::Char('C'), + KeyModifiers::CONTROL, + ))); + assert!(!side_return_shortcut_matches(KeyEvent::new( + KeyCode::Char('d'), + KeyModifiers::CONTROL, + ))); + assert!(!side_return_shortcut_matches(KeyEvent::new_with_kind( + KeyCode::Esc, + KeyModifiers::NONE, + KeyEventKind::Release, + ))); + } +} diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 0551d3253..89d5e1d43 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -730,3 +730,61 @@ impl App { Ok(AppRunControl::Continue) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn terminal_thread_read_error_detection_matches_not_loaded_errors() { + let err = color_eyre::eyre::eyre!( + "thread/read failed during TUI session lookup: thread/read failed: thread not loaded: thr_123" + ); + + assert!(App::is_terminal_thread_read_error(&err)); + } + + #[test] + fn terminal_thread_read_error_detection_ignores_transient_failures() { + let err = color_eyre::eyre::eyre!( + "thread/read failed during TUI session lookup: thread/read transport error: broken pipe" + ); + + assert!(!App::is_terminal_thread_read_error(&err)); + } + + #[test] + fn closed_state_for_thread_read_error_preserves_live_state_without_cache_on_transient_error() { + let err = color_eyre::eyre::eyre!( + "thread/read failed during TUI session lookup: thread/read transport error: broken pipe" + ); + + assert!(!App::closed_state_for_thread_read_error( + &err, /*existing_is_closed*/ None + )); + } + + #[test] + fn closed_state_for_thread_read_error_marks_terminal_uncached_threads_closed() { + let err = color_eyre::eyre::eyre!( + "thread/read failed during TUI session lookup: thread/read failed: thread not loaded: thr_123" + ); + + assert!(App::closed_state_for_thread_read_error( + &err, /*existing_is_closed*/ None + )); + } + + #[test] + fn include_turns_fallback_detection_handles_unmaterialized_and_ephemeral_threads() { + let unmaterialized = color_eyre::eyre::eyre!( + "thread/read failed during TUI session lookup: thread/read failed: thread thr_123 is not materialized yet; includeTurns is unavailable before first user message" + ); + let ephemeral = color_eyre::eyre::eyre!( + "thread/read failed during TUI session lookup: thread/read failed: ephemeral threads do not support includeTurns" + ); + + assert!(App::can_fallback_from_include_turns_error(&unmaterialized)); + assert!(App::can_fallback_from_include_turns_error(&ephemeral)); + } +} diff --git a/codex-rs/tui/src/app/side.rs b/codex-rs/tui/src/app/side.rs index e951233c5..4ca3785eb 100644 --- a/codex-rs/tui/src/app/side.rs +++ b/codex-rs/tui/src/app/side.rs @@ -97,6 +97,57 @@ impl SideParentStatus { } } +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn side_boundary_prompt_marks_inherited_history_reference_only() { + let item = App::side_boundary_prompt_item(); + let ResponseItem::Message { role, content, .. } = item else { + panic!("expected hidden side boundary prompt to be a user message"); + }; + assert_eq!(role, "user"); + let [ContentItem::InputText { text }] = content.as_slice() else { + panic!("expected hidden side boundary prompt text"); + }; + assert!(text.contains("Side conversation boundary.")); + assert!(text.contains("Everything before this boundary is inherited history")); + assert!(text.contains("It is not your current task.")); + assert!(text.contains("Only messages submitted after this boundary are active")); + assert!(text.contains("Do not continue, execute, or complete")); + assert!(text.contains("separate from the main thread")); + assert!( + text.contains("External tools may be available according to this thread's current") + ); + assert!(text.contains("Any tool calls or outputs visible before this boundary happened")); + assert!(text.contains("Do not modify files")); + } + + #[test] + fn side_start_error_message_explains_missing_first_prompt() { + let err = color_eyre::eyre::eyre!( + "thread/fork failed during TUI bootstrap: thread/fork failed: no rollout found for thread id 019da1a1-bed9-7a43-88a2-b49d43915021" + ); + + assert_eq!( + App::side_start_error_message(&err), + "'/side' is unavailable until the current conversation has started. Send a message first, then try /side again." + ); + } + + #[test] + fn side_start_error_message_uses_generic_start_wording() { + let err = color_eyre::eyre::eyre!("transport disconnected"); + + assert_eq!( + App::side_start_error_message(&err), + "Failed to start side conversation: transport disconnected" + ); + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) enum SideParentStatusChange { Set(SideParentStatus), diff --git a/codex-rs/tui/src/app/startup_prompts.rs b/codex-rs/tui/src/app/startup_prompts.rs index 23c116ee5..c5bc541a0 100644 --- a/codex-rs/tui/src/app/startup_prompts.rs +++ b/codex-rs/tui/src/app/startup_prompts.rs @@ -324,3 +324,31 @@ pub(super) fn normalize_harness_overrides_for_cwd( overrides.additional_writable_roots = normalized; Ok(overrides) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::PathBufExt; + use pretty_assertions::assert_eq; + use std::path::PathBuf; + use tempfile::tempdir; + + #[test] + fn normalize_harness_overrides_resolves_relative_add_dirs() -> Result<()> { + let temp_dir = tempdir()?; + let base_cwd = temp_dir.path().join("base").abs(); + std::fs::create_dir_all(base_cwd.as_path())?; + + let overrides = ConfigOverrides { + additional_writable_roots: vec![PathBuf::from("rel")], + ..Default::default() + }; + let normalized = normalize_harness_overrides_for_cwd(overrides, &base_cwd)?; + + assert_eq!( + normalized.additional_writable_roots, + vec![base_cwd.join("rel").into_path_buf()] + ); + Ok(()) + } +} diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs new file mode 100644 index 000000000..8b2dfe8d4 --- /dev/null +++ b/codex-rs/tui/src/app/test_support.rs @@ -0,0 +1,89 @@ +//! Shared App fixtures for app submodule unit tests. +//! +//! This module keeps heavyweight `App` construction and config-inspection helpers available to +//! focused sibling test modules without making `app/tests.rs` the only practical place to test +//! app-owned behavior. + +use super::*; +use crate::chatwidget::tests::make_chatwidget_manual_with_sender; + +pub(super) async fn make_test_app() -> App { + let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await; + let config = chat_widget.config_ref().clone(); + let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone()); + let model = crate::legacy_core::test_support::get_model_offline(config.model.as_deref()); + let session_telemetry = test_session_telemetry(&config, model.as_str()); + + App { + model_catalog: chat_widget.model_catalog(), + session_telemetry, + app_event_tx, + chat_widget, + config, + active_profile: None, + cli_kv_overrides: Vec::new(), + harness_overrides: ConfigOverrides::default(), + runtime_approval_policy_override: None, + runtime_sandbox_policy_override: None, + file_search, + transcript_cells: Vec::new(), + overlay: None, + deferred_history_lines: Vec::new(), + has_emitted_history_lines: false, + enhanced_keys_supported: false, + commit_anim_running: Arc::new(AtomicBool::new(false)), + status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), + terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), + backtrack: BacktrackState::default(), + backtrack_render_pending: false, + feedback: codex_feedback::CodexFeedback::new(), + feedback_audience: FeedbackAudience::External, + environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), + remote_app_server_url: None, + remote_app_server_auth_token: None, + pending_update_action: None, + pending_shutdown_exit_thread_id: None, + windows_sandbox: WindowsSandboxState::default(), + thread_event_channels: HashMap::new(), + thread_event_listener_tasks: HashMap::new(), + agent_navigation: AgentNavigationState::default(), + side_threads: HashMap::new(), + active_thread_id: None, + active_thread_rx: None, + primary_thread_id: None, + last_subagent_backfill_attempt: None, + primary_session_configured: None, + pending_primary_events: VecDeque::new(), + pending_app_server_requests: PendingAppServerRequests::default(), + pending_plugin_enabled_writes: HashMap::new(), + } +} + +fn test_session_telemetry(config: &Config, model: &str) -> SessionTelemetry { + let model_info = crate::legacy_core::test_support::construct_model_info_offline(model, config); + SessionTelemetry::new( + ThreadId::new(), + model, + model_info.slug.as_str(), + /*account_id*/ None, + /*account_email*/ None, + /*auth_mode*/ None, + "test_originator".to_string(), + /*log_user_prompts*/ false, + "test".to_string(), + SessionSource::Cli, + ) +} + +pub(super) fn app_enabled_in_effective_config(config: &Config, app_id: &str) -> Option { + config + .config_layer_stack + .effective_config() + .as_table() + .and_then(|table| table.get("apps")) + .and_then(TomlValue::as_table) + .and_then(|apps| apps.get(app_id)) + .and_then(TomlValue::as_table) + .and_then(|app| app.get("enabled")) + .and_then(TomlValue::as_bool) +} diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 5ed786ef0..d34a49ec5 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -28,16 +28,6 @@ use codex_app_server_protocol::AdditionalPermissionProfile; use codex_app_server_protocol::AgentMessageDeltaNotification; use codex_app_server_protocol::CommandExecutionRequestApprovalParams; use codex_app_server_protocol::ConfigWarningNotification; -use codex_app_server_protocol::HookCompletedNotification; -use codex_app_server_protocol::HookEventName as AppServerHookEventName; -use codex_app_server_protocol::HookExecutionMode as AppServerHookExecutionMode; -use codex_app_server_protocol::HookHandlerType as AppServerHookHandlerType; -use codex_app_server_protocol::HookOutputEntry as AppServerHookOutputEntry; -use codex_app_server_protocol::HookOutputEntryKind as AppServerHookOutputEntryKind; -use codex_app_server_protocol::HookRunStatus as AppServerHookRunStatus; -use codex_app_server_protocol::HookRunSummary as AppServerHookRunSummary; -use codex_app_server_protocol::HookScope as AppServerHookScope; -use codex_app_server_protocol::HookStartedNotification; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::McpServerStartupState; use codex_app_server_protocol::McpServerStatusUpdatedNotification; @@ -47,7 +37,6 @@ use codex_app_server_protocol::NetworkPolicyAmendment as AppServerNetworkPolicyA use codex_app_server_protocol::NetworkPolicyRuleAction as AppServerNetworkPolicyRuleAction; use codex_app_server_protocol::NonSteerableTurnKind as AppServerNonSteerableTurnKind; use codex_app_server_protocol::PermissionsRequestApprovalParams; -use codex_app_server_protocol::PluginMarketplaceEntry; use codex_app_server_protocol::RequestId as AppServerRequestId; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; @@ -72,14 +61,12 @@ use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; -use codex_protocol::mcp::Tool; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::NetworkPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::McpAuthStatus; use codex_protocol::protocol::NetworkApprovalContext; use codex_protocol::protocol::NetworkApprovalProtocol; use codex_protocol::protocol::RolloutItem; @@ -114,109 +101,6 @@ fn test_absolute_path(path: &str) -> AbsolutePathBuf { AbsolutePathBuf::try_from(PathBuf::from(path)).expect("absolute test path") } -#[test] -fn hide_cli_only_plugin_marketplaces_removes_openai_bundled() { - let mut response = PluginListResponse { - marketplaces: vec![ - PluginMarketplaceEntry { - name: "openai-bundled".to_string(), - path: Some(test_absolute_path("/marketplaces/openai-bundled")), - interface: None, - plugins: Vec::new(), - }, - PluginMarketplaceEntry { - name: "openai-curated".to_string(), - path: Some(test_absolute_path("/marketplaces/openai-curated")), - interface: None, - plugins: Vec::new(), - }, - ], - marketplace_load_errors: Vec::new(), - featured_plugin_ids: Vec::new(), - }; - - hide_cli_only_plugin_marketplaces(&mut response); - - assert_eq!( - response.marketplaces, - vec![PluginMarketplaceEntry { - name: "openai-curated".to_string(), - path: Some(test_absolute_path("/marketplaces/openai-curated")), - interface: None, - plugins: Vec::new(), - }] - ); -} - -#[test] -fn normalize_harness_overrides_resolves_relative_add_dirs() -> Result<()> { - let temp_dir = tempdir()?; - let base_cwd = temp_dir.path().join("base").abs(); - std::fs::create_dir_all(base_cwd.as_path())?; - - let overrides = ConfigOverrides { - additional_writable_roots: vec![PathBuf::from("rel")], - ..Default::default() - }; - let normalized = normalize_harness_overrides_for_cwd(overrides, &base_cwd)?; - - assert_eq!( - normalized.additional_writable_roots, - vec![base_cwd.join("rel").into_path_buf()] - ); - Ok(()) -} - -#[test] -fn mcp_inventory_maps_prefix_tool_names_by_server() { - let statuses = vec![ - McpServerStatus { - name: "docs".to_string(), - tools: HashMap::from([( - "list".to_string(), - Tool { - description: None, - name: "list".to_string(), - title: None, - input_schema: serde_json::json!({"type": "object"}), - output_schema: None, - annotations: None, - icons: None, - meta: None, - }, - )]), - resources: Vec::new(), - resource_templates: Vec::new(), - auth_status: codex_app_server_protocol::McpAuthStatus::Unsupported, - }, - McpServerStatus { - name: "disabled".to_string(), - tools: HashMap::new(), - resources: Vec::new(), - resource_templates: Vec::new(), - auth_status: codex_app_server_protocol::McpAuthStatus::Unsupported, - }, - ]; - - let (tools, resources, resource_templates, auth_statuses) = - mcp_inventory_maps_from_statuses(statuses); - let mut resource_names = resources.keys().cloned().collect::>(); - resource_names.sort(); - let mut template_names = resource_templates.keys().cloned().collect::>(); - template_names.sort(); - - assert_eq!( - tools.keys().cloned().collect::>(), - vec!["mcp__docs__list".to_string()] - ); - assert_eq!(resource_names, vec!["disabled", "docs"]); - assert_eq!(template_names, vec!["disabled", "docs"]); - assert_eq!( - auth_statuses.get("disabled"), - Some(&McpAuthStatus::Unsupported) - ); -} - #[tokio::test] async fn handle_mcp_inventory_result_clears_committed_loading_cell() { let mut app = make_test_app().await; @@ -1438,59 +1322,6 @@ async fn open_agent_picker_marks_terminal_read_errors_closed() -> Result<()> { Ok(()) } -#[test] -fn terminal_thread_read_error_detection_matches_not_loaded_errors() { - let err = color_eyre::eyre::eyre!( - "thread/read failed during TUI session lookup: thread/read failed: thread not loaded: thr_123" - ); - - assert!(App::is_terminal_thread_read_error(&err)); -} - -#[test] -fn terminal_thread_read_error_detection_ignores_transient_failures() { - let err = color_eyre::eyre::eyre!( - "thread/read failed during TUI session lookup: thread/read transport error: broken pipe" - ); - - assert!(!App::is_terminal_thread_read_error(&err)); -} - -#[test] -fn closed_state_for_thread_read_error_preserves_live_state_without_cache_on_transient_error() { - let err = color_eyre::eyre::eyre!( - "thread/read failed during TUI session lookup: thread/read transport error: broken pipe" - ); - - assert!(!App::closed_state_for_thread_read_error( - &err, /*existing_is_closed*/ None - )); -} - -#[test] -fn closed_state_for_thread_read_error_marks_terminal_uncached_threads_closed() { - let err = color_eyre::eyre::eyre!( - "thread/read failed during TUI session lookup: thread/read failed: thread not loaded: thr_123" - ); - - assert!(App::closed_state_for_thread_read_error( - &err, /*existing_is_closed*/ None - )); -} - -#[test] -fn include_turns_fallback_detection_handles_unmaterialized_and_ephemeral_threads() { - let unmaterialized = color_eyre::eyre::eyre!( - "thread/read failed during TUI session lookup: thread/read failed: thread thr_123 is not materialized yet; includeTurns is unavailable before first user message" - ); - let ephemeral = color_eyre::eyre::eyre!( - "thread/read failed during TUI session lookup: thread/read failed: ephemeral threads do not support includeTurns" - ); - - assert!(App::can_fallback_from_include_turns_error(&unmaterialized)); - assert!(App::can_fallback_from_include_turns_error(&ephemeral)); -} - #[tokio::test] async fn open_agent_picker_marks_loaded_threads_open() -> Result<()> { let mut app = make_test_app().await; @@ -3080,57 +2911,6 @@ async fn side_fork_config_is_ephemeral_and_appends_developer_guardrails() { assert!(app.transcript_cells.is_empty()); } -#[test] -fn side_boundary_prompt_marks_inherited_history_reference_only() { - let item = App::side_boundary_prompt_item(); - let codex_protocol::models::ResponseItem::Message { role, content, .. } = item else { - panic!("expected hidden side boundary prompt to be a user message"); - }; - assert_eq!(role, "user"); - let [codex_protocol::models::ContentItem::InputText { text }] = content.as_slice() else { - panic!("expected hidden side boundary prompt text"); - }; - assert!(text.contains("Side conversation boundary.")); - assert!(text.contains("Everything before this boundary is inherited history")); - assert!(text.contains("It is not your current task.")); - assert!(text.contains("Only messages submitted after this boundary are active")); - assert!(text.contains("Do not continue, execute, or complete")); - assert!(text.contains("separate from the main thread")); - assert!(text.contains("External tools may be available according to this thread's current")); - assert!(text.contains("Any tool calls or outputs visible before this boundary happened")); - assert!(text.contains("Do not modify files")); -} - -#[test] -fn side_return_shortcuts_match_esc_and_ctrl_c() { - assert!(side_return_shortcut_matches(KeyEvent::new( - KeyCode::Esc, - KeyModifiers::NONE, - ))); - assert!(side_return_shortcut_matches(KeyEvent::new_with_kind( - KeyCode::Esc, - KeyModifiers::NONE, - KeyEventKind::Repeat, - ))); - assert!(side_return_shortcut_matches(KeyEvent::new( - KeyCode::Char('c'), - KeyModifiers::CONTROL, - ))); - assert!(side_return_shortcut_matches(KeyEvent::new( - KeyCode::Char('C'), - KeyModifiers::CONTROL, - ))); - assert!(!side_return_shortcut_matches(KeyEvent::new( - KeyCode::Char('d'), - KeyModifiers::CONTROL, - ))); - assert!(!side_return_shortcut_matches(KeyEvent::new_with_kind( - KeyCode::Esc, - KeyModifiers::NONE, - KeyEventKind::Release, - ))); -} - #[tokio::test] async fn side_start_block_message_tracks_open_side_conversation() { let mut app = make_test_app().await; @@ -3281,28 +3061,6 @@ async fn side_parent_status_prioritizes_input_over_approval() -> Result<()> { Ok(()) } -#[test] -fn side_start_error_message_explains_missing_first_prompt() { - let err = color_eyre::eyre::eyre!( - "thread/fork failed during TUI bootstrap: thread/fork failed: no rollout found for thread id 019da1a1-bed9-7a43-88a2-b49d43915021" - ); - - assert_eq!( - App::side_start_error_message(&err), - "'/side' is unavailable until the current conversation has started. Send a message first, then try /side again." - ); -} - -#[test] -fn side_start_error_message_uses_generic_start_wording() { - let err = color_eyre::eyre::eyre!("transport disconnected"); - - assert_eq!( - App::side_start_error_message(&err), - "Failed to start side conversation: transport disconnected" - ); -} - #[tokio::test] async fn side_thread_snapshot_hides_forked_parent_transcript() { let parent_thread_id = ThreadId::new(); @@ -3991,61 +3749,6 @@ fn token_usage_notification( }) } -fn hook_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification { - ServerNotification::HookStarted(HookStartedNotification { - thread_id: thread_id.to_string(), - turn_id: Some(turn_id.to_string()), - run: AppServerHookRunSummary { - id: "user-prompt-submit:0:/tmp/hooks.json".to_string(), - event_name: AppServerHookEventName::UserPromptSubmit, - handler_type: AppServerHookHandlerType::Command, - execution_mode: AppServerHookExecutionMode::Sync, - scope: AppServerHookScope::Turn, - source_path: test_path_buf("/tmp/hooks.json").abs(), - source: codex_app_server_protocol::HookSource::User, - display_order: 0, - status: AppServerHookRunStatus::Running, - status_message: Some("checking go-workflow input policy".to_string()), - started_at: 1, - completed_at: None, - duration_ms: None, - entries: Vec::new(), - }, - }) -} - -fn hook_completed_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification { - ServerNotification::HookCompleted(HookCompletedNotification { - thread_id: thread_id.to_string(), - turn_id: Some(turn_id.to_string()), - run: AppServerHookRunSummary { - id: "user-prompt-submit:0:/tmp/hooks.json".to_string(), - event_name: AppServerHookEventName::UserPromptSubmit, - handler_type: AppServerHookHandlerType::Command, - execution_mode: AppServerHookExecutionMode::Sync, - scope: AppServerHookScope::Turn, - source_path: test_path_buf("/tmp/hooks.json").abs(), - source: codex_app_server_protocol::HookSource::User, - display_order: 0, - status: AppServerHookRunStatus::Stopped, - status_message: Some("checking go-workflow input policy".to_string()), - started_at: 1, - completed_at: Some(11), - duration_ms: Some(10), - entries: vec![ - AppServerHookOutputEntry { - kind: AppServerHookOutputEntryKind::Warning, - text: "go-workflow must start from PlanMode".to_string(), - }, - AppServerHookOutputEntry { - kind: AppServerHookOutputEntryKind::Stop, - text: "prompt blocked".to_string(), - }, - ], - }, - }) -} - fn agent_message_delta_notification( thread_id: ThreadId, turn_id: &str, @@ -4098,162 +3801,6 @@ fn request_user_input_request(thread_id: ThreadId, turn_id: &str, item_id: &str) } } -#[test] -fn thread_event_store_tracks_active_turn_lifecycle() { - let mut store = ThreadEventStore::new(/*capacity*/ 8); - assert_eq!(store.active_turn_id(), None); - - let thread_id = ThreadId::new(); - store.push_notification(turn_started_notification(thread_id, "turn-1")); - assert_eq!(store.active_turn_id(), Some("turn-1")); - - store.push_notification(turn_completed_notification( - thread_id, - "turn-2", - TurnStatus::Completed, - )); - assert_eq!(store.active_turn_id(), Some("turn-1")); - - store.push_notification(turn_completed_notification( - thread_id, - "turn-1", - TurnStatus::Interrupted, - )); - assert_eq!(store.active_turn_id(), None); -} - -#[test] -fn thread_event_store_restores_active_turn_from_snapshot_turns() { - let thread_id = ThreadId::new(); - let session = test_thread_session(thread_id, test_path_buf("/tmp/project")); - let turns = vec![ - test_turn("turn-1", TurnStatus::Completed, Vec::new()), - test_turn("turn-2", TurnStatus::InProgress, Vec::new()), - ]; - - let store = - ThreadEventStore::new_with_session(/*capacity*/ 8, session.clone(), turns.clone()); - assert_eq!(store.active_turn_id(), Some("turn-2")); - - let mut refreshed_store = ThreadEventStore::new(/*capacity*/ 8); - refreshed_store.set_session(session, turns); - assert_eq!(refreshed_store.active_turn_id(), Some("turn-2")); -} - -#[test] -fn thread_event_store_clear_active_turn_id_resets_cached_turn() { - let mut store = ThreadEventStore::new(/*capacity*/ 8); - let thread_id = ThreadId::new(); - store.push_notification(turn_started_notification(thread_id, "turn-1")); - - store.clear_active_turn_id(); - - assert_eq!(store.active_turn_id(), None); -} - -#[test] -fn thread_event_store_rebase_preserves_resolved_request_state() { - let thread_id = ThreadId::new(); - let mut store = ThreadEventStore::new(/*capacity*/ 8); - store.push_request(exec_approval_request( - thread_id, - "turn-approval", - "call-approval", - /*approval_id*/ None, - )); - store.push_notification(ServerNotification::ServerRequestResolved( - codex_app_server_protocol::ServerRequestResolvedNotification { - request_id: AppServerRequestId::Integer(1), - thread_id: thread_id.to_string(), - }, - )); - - store.rebase_buffer_after_session_refresh(); - - let snapshot = store.snapshot(); - assert!(snapshot.events.is_empty()); - assert_eq!(store.has_pending_thread_approvals(), false); -} - -#[test] -fn thread_event_store_rebase_preserves_hook_notifications() { - let thread_id = ThreadId::new(); - let mut store = ThreadEventStore::new(/*capacity*/ 8); - store.push_notification(hook_started_notification(thread_id, "turn-hook")); - store.push_notification(hook_completed_notification(thread_id, "turn-hook")); - - store.rebase_buffer_after_session_refresh(); - - let snapshot = store.snapshot(); - let hook_notifications = snapshot - .events - .into_iter() - .map(|event| match event { - ThreadBufferedEvent::Notification(notification) => { - serde_json::to_value(notification).expect("hook notification should serialize") - } - other => panic!("expected buffered hook notification, saw: {other:?}"), - }) - .collect::>(); - assert_eq!( - hook_notifications, - vec![ - serde_json::to_value(hook_started_notification(thread_id, "turn-hook")) - .expect("hook notification should serialize"), - serde_json::to_value(hook_completed_notification(thread_id, "turn-hook")) - .expect("hook notification should serialize"), - ] - ); -} - -#[test] -fn build_feedback_upload_params_includes_thread_id_and_rollout_path() { - let thread_id = ThreadId::new(); - let rollout_path = PathBuf::from("/tmp/rollout.jsonl"); - - let params = build_feedback_upload_params( - Some(thread_id), - Some(rollout_path.clone()), - FeedbackCategory::SafetyCheck, - Some("needs follow-up".to_string()), - Some("turn-123".to_string()), - /*include_logs*/ true, - ); - - assert_eq!(params.classification, "safety_check"); - assert_eq!(params.reason, Some("needs follow-up".to_string())); - assert_eq!(params.thread_id, Some(thread_id.to_string())); - assert_eq!( - params - .tags - .as_ref() - .and_then(|tags| tags.get("turn_id")) - .map(String::as_str), - Some("turn-123") - ); - assert_eq!(params.include_logs, true); - assert_eq!(params.extra_log_files, Some(vec![rollout_path])); -} - -#[test] -fn build_feedback_upload_params_omits_rollout_path_without_logs() { - let params = build_feedback_upload_params( - /*origin_thread_id*/ None, - Some(PathBuf::from("/tmp/rollout.jsonl")), - FeedbackCategory::GoodResult, - /*reason*/ None, - /*turn_id*/ None, - /*include_logs*/ false, - ); - - assert_eq!(params.classification, "good_result"); - assert_eq!(params.reason, None); - assert_eq!(params.thread_id, None); - assert_eq!(params.tags, None); - assert_eq!(params.include_logs, false); - assert_eq!(params.extra_log_files, None); -} - #[tokio::test] async fn feedback_submission_without_thread_emits_error_history_cell() { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; @@ -4383,19 +3930,6 @@ fn test_session_telemetry(config: &Config, model: &str) -> SessionTelemetry { ) } -fn app_enabled_in_effective_config(config: &Config, app_id: &str) -> Option { - config - .config_layer_stack - .effective_config() - .as_table() - .and_then(|table| table.get("apps")) - .and_then(TomlValue::as_table) - .and_then(|apps| apps.get(app_id)) - .and_then(TomlValue::as_table) - .and_then(|app| app.get("enabled")) - .and_then(TomlValue::as_bool) -} - #[test] fn active_turn_not_steerable_turn_error_extracts_structured_server_error() { let turn_error = AppServerTurnError { @@ -4457,166 +3991,6 @@ fn active_turn_steer_race_extracts_actual_turn_id_from_mismatch() { ); } -#[tokio::test] -async fn update_reasoning_effort_updates_collaboration_mode() { - let mut app = make_test_app().await; - app.chat_widget - .set_reasoning_effort(Some(ReasoningEffortConfig::Medium)); - - app.on_update_reasoning_effort(Some(ReasoningEffortConfig::High)); - - assert_eq!( - app.chat_widget.current_reasoning_effort(), - Some(ReasoningEffortConfig::High) - ); - assert_eq!( - app.config.model_reasoning_effort, - Some(ReasoningEffortConfig::High) - ); -} - -#[tokio::test] -async fn refresh_in_memory_config_from_disk_loads_latest_apps_state() -> Result<()> { - let mut app = make_test_app().await; - let codex_home = tempdir()?; - app.config.codex_home = codex_home.path().to_path_buf().abs(); - let app_id = "unit_test_refresh_in_memory_config_connector".to_string(); - - assert_eq!(app_enabled_in_effective_config(&app.config, &app_id), None); - - ConfigEditsBuilder::new(&app.config.codex_home) - .with_edits([ - ConfigEdit::SetPath { - segments: vec!["apps".to_string(), app_id.clone(), "enabled".to_string()], - value: false.into(), - }, - ConfigEdit::SetPath { - segments: vec![ - "apps".to_string(), - app_id.clone(), - "disabled_reason".to_string(), - ], - value: "user".into(), - }, - ]) - .apply() - .await - .expect("persist app toggle"); - - assert_eq!(app_enabled_in_effective_config(&app.config, &app_id), None); - - app.refresh_in_memory_config_from_disk().await?; - - assert_eq!( - app_enabled_in_effective_config(&app.config, &app_id), - Some(false) - ); - Ok(()) -} - -#[tokio::test] -async fn refresh_in_memory_config_from_disk_best_effort_keeps_current_config_on_error() -> Result<()> -{ - let mut app = make_test_app().await; - let codex_home = tempdir()?; - app.config.codex_home = codex_home.path().to_path_buf().abs(); - std::fs::write(codex_home.path().join("config.toml"), "[broken")?; - let original_config = app.config.clone(); - - app.refresh_in_memory_config_from_disk_best_effort("starting a new thread") - .await; - - assert_eq!(app.config, original_config); - Ok(()) -} - -#[tokio::test] -async fn refresh_in_memory_config_from_disk_uses_active_chat_widget_cwd() -> Result<()> { - let mut app = make_test_app().await; - let original_cwd = app.config.cwd.clone(); - let next_cwd_tmp = tempdir()?; - let next_cwd = next_cwd_tmp.path().to_path_buf(); - - app.chat_widget.handle_codex_event(Event { - id: String::new(), - msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id: ThreadId::new(), - forked_from_id: None, - thread_name: None, - model: "gpt-test".to_string(), - model_provider_id: "test-provider".to_string(), - service_tier: None, - approval_policy: AskForApproval::Never, - approvals_reviewer: ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - cwd: next_cwd.clone().abs(), - reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, - initial_messages: None, - network_proxy: None, - rollout_path: Some(PathBuf::new()), - }), - }); - - assert_eq!(app.chat_widget.config_ref().cwd.to_path_buf(), next_cwd); - assert_eq!(app.config.cwd, original_cwd); - - app.refresh_in_memory_config_from_disk().await?; - - assert_eq!(app.config.cwd, app.chat_widget.config_ref().cwd); - Ok(()) -} - -#[tokio::test] -async fn rebuild_config_for_resume_or_fallback_uses_current_config_on_same_cwd_error() -> Result<()> -{ - let mut app = make_test_app().await; - let codex_home = tempdir()?; - app.config.codex_home = codex_home.path().to_path_buf().abs(); - std::fs::write(codex_home.path().join("config.toml"), "[broken")?; - let current_config = app.config.clone(); - let current_cwd = current_config.cwd.clone(); - - let resume_config = app - .rebuild_config_for_resume_or_fallback(¤t_cwd, current_cwd.to_path_buf()) - .await?; - - assert_eq!(resume_config, current_config); - Ok(()) -} - -#[tokio::test] -async fn rebuild_config_for_resume_or_fallback_errors_when_cwd_changes() -> Result<()> { - let mut app = make_test_app().await; - let codex_home = tempdir()?; - app.config.codex_home = codex_home.path().to_path_buf().abs(); - std::fs::write(codex_home.path().join("config.toml"), "[broken")?; - let current_cwd = app.config.cwd.clone(); - let next_cwd_tmp = tempdir()?; - let next_cwd = next_cwd_tmp.path().to_path_buf(); - - let result = app - .rebuild_config_for_resume_or_fallback(¤t_cwd, next_cwd) - .await; - - assert!(result.is_err()); - Ok(()) -} - -#[tokio::test] -async fn sync_tui_theme_selection_updates_chat_widget_config_copy() { - let mut app = make_test_app().await; - - app.sync_tui_theme_selection("dracula".to_string()); - - assert_eq!(app.config.tui_theme.as_deref(), Some("dracula")); - assert_eq!( - app.chat_widget.config_ref().tui_theme.as_deref(), - Some("dracula") - ); -} - #[tokio::test] async fn fresh_session_config_uses_current_service_tier() { let mut app = make_test_app().await; diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index 6bbeca04e..89cef2332 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -264,3 +264,277 @@ impl ThreadEventChannel { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::PathBufExt; + use crate::test_support::test_path_buf; + use codex_app_server_protocol::CommandExecutionRequestApprovalParams; + use codex_app_server_protocol::HookCompletedNotification; + use codex_app_server_protocol::HookEventName as AppServerHookEventName; + use codex_app_server_protocol::HookExecutionMode as AppServerHookExecutionMode; + use codex_app_server_protocol::HookHandlerType as AppServerHookHandlerType; + use codex_app_server_protocol::HookOutputEntry as AppServerHookOutputEntry; + use codex_app_server_protocol::HookOutputEntryKind as AppServerHookOutputEntryKind; + use codex_app_server_protocol::HookRunStatus as AppServerHookRunStatus; + use codex_app_server_protocol::HookRunSummary as AppServerHookRunSummary; + use codex_app_server_protocol::HookScope as AppServerHookScope; + use codex_app_server_protocol::HookStartedNotification; + use codex_app_server_protocol::RequestId as AppServerRequestId; + use codex_app_server_protocol::TurnCompletedNotification; + use codex_app_server_protocol::TurnStartedNotification; + use codex_config::types::ApprovalsReviewer; + use codex_protocol::protocol::AskForApproval; + use codex_protocol::protocol::SandboxPolicy; + use pretty_assertions::assert_eq; + use std::path::PathBuf; + + fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState { + ThreadSessionState { + thread_id, + forked_from_id: None, + fork_parent_title: None, + thread_name: None, + model: "gpt-test".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: cwd.abs(), + instruction_source_paths: Vec::new(), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + network_proxy: None, + rollout_path: Some(PathBuf::new()), + } + } + + fn test_turn(turn_id: &str, status: TurnStatus, items: Vec) -> Turn { + Turn { + id: turn_id.to_string(), + items, + status, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + } + } + + fn turn_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + started_at: Some(0), + ..test_turn(turn_id, TurnStatus::InProgress, Vec::new()) + }, + }) + } + + fn turn_completed_notification( + thread_id: ThreadId, + turn_id: &str, + status: TurnStatus, + ) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + completed_at: Some(0), + duration_ms: Some(1), + ..test_turn(turn_id, status, Vec::new()) + }, + }) + } + + fn hook_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification { + ServerNotification::HookStarted(HookStartedNotification { + thread_id: thread_id.to_string(), + turn_id: Some(turn_id.to_string()), + run: AppServerHookRunSummary { + id: "user-prompt-submit:0:/tmp/hooks.json".to_string(), + event_name: AppServerHookEventName::UserPromptSubmit, + handler_type: AppServerHookHandlerType::Command, + execution_mode: AppServerHookExecutionMode::Sync, + scope: AppServerHookScope::Turn, + source_path: test_path_buf("/tmp/hooks.json").abs(), + source: codex_app_server_protocol::HookSource::User, + display_order: 0, + status: AppServerHookRunStatus::Running, + status_message: Some("checking go-workflow input policy".to_string()), + started_at: 1, + completed_at: None, + duration_ms: None, + entries: Vec::new(), + }, + }) + } + + fn hook_completed_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification { + ServerNotification::HookCompleted(HookCompletedNotification { + thread_id: thread_id.to_string(), + turn_id: Some(turn_id.to_string()), + run: AppServerHookRunSummary { + id: "user-prompt-submit:0:/tmp/hooks.json".to_string(), + event_name: AppServerHookEventName::UserPromptSubmit, + handler_type: AppServerHookHandlerType::Command, + execution_mode: AppServerHookExecutionMode::Sync, + scope: AppServerHookScope::Turn, + source_path: test_path_buf("/tmp/hooks.json").abs(), + source: codex_app_server_protocol::HookSource::User, + display_order: 0, + status: AppServerHookRunStatus::Stopped, + status_message: Some("checking go-workflow input policy".to_string()), + started_at: 1, + completed_at: Some(11), + duration_ms: Some(10), + entries: vec![ + AppServerHookOutputEntry { + kind: AppServerHookOutputEntryKind::Warning, + text: "go-workflow must start from PlanMode".to_string(), + }, + AppServerHookOutputEntry { + kind: AppServerHookOutputEntryKind::Stop, + text: "prompt blocked".to_string(), + }, + ], + }, + }) + } + + fn exec_approval_request( + thread_id: ThreadId, + turn_id: &str, + item_id: &str, + approval_id: Option<&str>, + ) -> ServerRequest { + ServerRequest::CommandExecutionRequestApproval { + request_id: AppServerRequestId::Integer(1), + params: CommandExecutionRequestApprovalParams { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + item_id: item_id.to_string(), + approval_id: approval_id.map(str::to_string), + reason: Some("needs approval".to_string()), + network_approval_context: None, + command: Some("echo hello".to_string()), + cwd: Some(test_path_buf("/tmp/project").abs()), + command_actions: None, + additional_permissions: None, + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + available_decisions: None, + }, + } + } + + #[test] + fn thread_event_store_tracks_active_turn_lifecycle() { + let mut store = ThreadEventStore::new(/*capacity*/ 8); + assert_eq!(store.active_turn_id(), None); + + let thread_id = ThreadId::new(); + store.push_notification(turn_started_notification(thread_id, "turn-1")); + assert_eq!(store.active_turn_id(), Some("turn-1")); + + store.push_notification(turn_completed_notification( + thread_id, + "turn-2", + TurnStatus::Completed, + )); + assert_eq!(store.active_turn_id(), Some("turn-1")); + + store.push_notification(turn_completed_notification( + thread_id, + "turn-1", + TurnStatus::Interrupted, + )); + assert_eq!(store.active_turn_id(), None); + } + + #[test] + fn thread_event_store_restores_active_turn_from_snapshot_turns() { + let thread_id = ThreadId::new(); + let session = test_thread_session(thread_id, test_path_buf("/tmp/project")); + let turns = vec![ + test_turn("turn-1", TurnStatus::Completed, Vec::new()), + test_turn("turn-2", TurnStatus::InProgress, Vec::new()), + ]; + + let store = + ThreadEventStore::new_with_session(/*capacity*/ 8, session.clone(), turns.clone()); + assert_eq!(store.active_turn_id(), Some("turn-2")); + + let mut refreshed_store = ThreadEventStore::new(/*capacity*/ 8); + refreshed_store.set_session(session, turns); + assert_eq!(refreshed_store.active_turn_id(), Some("turn-2")); + } + + #[test] + fn thread_event_store_clear_active_turn_id_resets_cached_turn() { + let mut store = ThreadEventStore::new(/*capacity*/ 8); + let thread_id = ThreadId::new(); + store.push_notification(turn_started_notification(thread_id, "turn-1")); + + store.clear_active_turn_id(); + + assert_eq!(store.active_turn_id(), None); + } + + #[test] + fn thread_event_store_rebase_preserves_resolved_request_state() { + let thread_id = ThreadId::new(); + let mut store = ThreadEventStore::new(/*capacity*/ 8); + store.push_request(exec_approval_request( + thread_id, + "turn-approval", + "call-approval", + /*approval_id*/ None, + )); + store.push_notification(ServerNotification::ServerRequestResolved( + codex_app_server_protocol::ServerRequestResolvedNotification { + request_id: AppServerRequestId::Integer(1), + thread_id: thread_id.to_string(), + }, + )); + + store.rebase_buffer_after_session_refresh(); + + let snapshot = store.snapshot(); + assert!(snapshot.events.is_empty()); + assert_eq!(store.has_pending_thread_approvals(), false); + } + + #[test] + fn thread_event_store_rebase_preserves_hook_notifications() { + let thread_id = ThreadId::new(); + let mut store = ThreadEventStore::new(/*capacity*/ 8); + store.push_notification(hook_started_notification(thread_id, "turn-hook")); + store.push_notification(hook_completed_notification(thread_id, "turn-hook")); + + store.rebase_buffer_after_session_refresh(); + + let snapshot = store.snapshot(); + let hook_notifications = snapshot + .events + .into_iter() + .map(|event| match event { + ThreadBufferedEvent::Notification(notification) => { + serde_json::to_value(notification).expect("hook notification should serialize") + } + other => panic!("expected buffered hook notification, saw: {other:?}"), + }) + .collect::>(); + assert_eq!( + hook_notifications, + vec![ + serde_json::to_value(hook_started_notification(thread_id, "turn-hook")) + .expect("hook notification should serialize"), + serde_json::to_value(hook_completed_notification(thread_id, "turn-hook")) + .expect("hook notification should serialize"), + ] + ); + } +}