diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 79bfc73ad44..277e31ba880 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1439,6 +1439,7 @@ dependencies = [ "codex-utils-cargo-bin", "codex-utils-cli", "codex-utils-json-to-toml", + "codex-utils-pty", "core_test_support", "futures", "opentelemetry", @@ -2438,7 +2439,6 @@ dependencies = [ "anyhow", "chrono", "clap", - "codex-otel", "codex-protocol", "dirs", "log", @@ -2480,10 +2480,12 @@ dependencies = [ "anyhow", "arboard", "assert_matches", + "async-trait", "base64 0.22.1", "chrono", "clap", "codex-ansi-escape", + "codex-app-server-client", "codex-app-server-protocol", "codex-arg0", "codex-backend-client", diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 80a328384fc..541be296556 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -36,6 +36,7 @@ use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::Result as JsonRpcResult; use codex_arg0::Arg0DispatchPaths; +use codex_core::ThreadManager; use codex_core::config::Config; use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::LoaderOverrides; @@ -129,6 +130,8 @@ pub struct InProcessClientStartArgs { pub arg0_paths: Arg0DispatchPaths, /// Shared config used to initialize app-server runtime. pub config: Arc, + /// Optional thread manager to reuse instead of creating a private one. + pub thread_manager: Option>, /// CLI config overrides that are already parsed into TOML values. pub cli_overrides: Vec<(String, TomlValue)>, /// Loader override knobs used by config API paths. @@ -182,6 +185,7 @@ impl InProcessClientStartArgs { InProcessStartArgs { arg0_paths: self.arg0_paths, config: self.config, + thread_manager: self.thread_manager, cli_overrides: self.cli_overrides, loader_overrides: self.loader_overrides, cloud_requirements: self.cloud_requirements, @@ -606,7 +610,10 @@ mod tests { use codex_app_server_protocol::SessionSource as ApiSessionSource; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; + use codex_core::CodexAuth; + use codex_core::ThreadManager; use codex_core::config::ConfigBuilder; + use codex_core::test_support::thread_manager_with_models_provider_and_home; use pretty_assertions::assert_eq; use tokio::time::Duration; use tokio::time::timeout; @@ -623,9 +630,11 @@ mod tests { session_source: SessionSource, channel_capacity: usize, ) -> InProcessAppServerClient { + let config = Arc::new(build_test_config().await); InProcessAppServerClient::start(InProcessClientStartArgs { arg0_paths: Arg0DispatchPaths::default(), - config: Arc::new(build_test_config().await), + config, + thread_manager: None, cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), cloud_requirements: CloudRequirementsLoader::default(), @@ -647,6 +656,14 @@ mod tests { start_test_client_with_capacity(session_source, DEFAULT_IN_PROCESS_CHANNEL_CAPACITY).await } + fn shared_test_thread_manager(config: &Config) -> Arc { + Arc::new(thread_manager_with_models_provider_and_home( + CodexAuth::from_api_key("test"), + config.model_provider.clone(), + config.codex_home.clone(), + )) + } + #[tokio::test] async fn typed_request_roundtrip_works() { let client = start_test_client(SessionSource::Exec).await; @@ -702,6 +719,98 @@ mod tests { } } + #[tokio::test] + async fn shared_thread_manager_observes_threads_started_in_process() { + let config = Arc::new(build_test_config().await); + let thread_manager = shared_test_thread_manager(&config); + let client = InProcessAppServerClient::start(InProcessClientStartArgs { + arg0_paths: Arg0DispatchPaths::default(), + config, + thread_manager: Some(Arc::clone(&thread_manager)), + cli_overrides: Vec::new(), + loader_overrides: LoaderOverrides::default(), + cloud_requirements: CloudRequirementsLoader::default(), + feedback: CodexFeedback::new(), + config_warnings: Vec::new(), + session_source: SessionSource::Cli, + enable_codex_api_key_env: false, + client_name: "codex-app-server-client-test".to_string(), + client_version: "0.0.0-test".to_string(), + experimental_api: true, + opt_out_notification_methods: Vec::new(), + channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, + }) + .await + .expect("in-process app-server client should start"); + + let response: ThreadStartResponse = client + .request_typed(ClientRequest::ThreadStart { + request_id: RequestId::Integer(3), + params: ThreadStartParams { + ephemeral: Some(true), + ..ThreadStartParams::default() + }, + }) + .await + .expect("thread/start should succeed"); + let thread_id = codex_protocol::ThreadId::from_string(&response.thread.id) + .expect("thread/start returned valid thread id"); + + thread_manager + .get_thread(thread_id) + .await + .expect("shared thread manager should see in-process threads"); + client.shutdown().await.expect("shutdown should complete"); + } + + #[tokio::test] + async fn shared_thread_manager_reuses_configured_auth_manager() { + let mut config = build_test_config().await; + config.forced_chatgpt_workspace_id = Some("workspace-123".to_string()); + let config = Arc::new(config); + let thread_manager = shared_test_thread_manager(&config); + + assert_eq!( + thread_manager.auth_manager().forced_chatgpt_workspace_id(), + None + ); + assert_eq!( + thread_manager.auth_manager().has_external_auth_refresher(), + false + ); + + let client = InProcessAppServerClient::start(InProcessClientStartArgs { + arg0_paths: Arg0DispatchPaths::default(), + config, + thread_manager: Some(Arc::clone(&thread_manager)), + cli_overrides: Vec::new(), + loader_overrides: LoaderOverrides::default(), + cloud_requirements: CloudRequirementsLoader::default(), + feedback: CodexFeedback::new(), + config_warnings: Vec::new(), + session_source: SessionSource::Cli, + enable_codex_api_key_env: false, + client_name: "codex-app-server-client-test".to_string(), + client_version: "0.0.0-test".to_string(), + experimental_api: true, + opt_out_notification_methods: Vec::new(), + channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, + }) + .await + .expect("in-process app-server client should start"); + + assert_eq!( + thread_manager.auth_manager().forced_chatgpt_workspace_id(), + Some("workspace-123".to_string()) + ); + assert_eq!( + thread_manager.auth_manager().has_external_auth_refresher(), + true + ); + + client.shutdown().await.expect("shutdown should complete"); + } + #[tokio::test] async fn tiny_channel_capacity_still_supports_request_roundtrip() { let client = start_test_client_with_capacity(SessionSource::Exec, 1).await; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 5b684a0eb5b..9e4cd8f61d8 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -3228,6 +3228,10 @@ impl CodexMessageProcessor { self.thread_manager.subscribe_thread_created() } + pub(crate) fn thread_manager(&self) -> &Arc { + &self.thread_manager + } + pub(crate) async fn connection_initialized(&self, connection_id: ConnectionId) { self.thread_state_manager .connection_initialized(connection_id) diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 6110fb52a52..6d65eb3cd88 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -74,6 +74,7 @@ use codex_app_server_protocol::Result; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_arg0::Arg0DispatchPaths; +use codex_core::ThreadManager; use codex_core::config::Config; use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::LoaderOverrides; @@ -116,6 +117,8 @@ pub struct InProcessStartArgs { pub arg0_paths: Arg0DispatchPaths, /// Shared base config used to initialize core components. pub config: Arc, + /// Optional thread manager to reuse instead of creating a private one. + pub thread_manager: Option>, /// CLI config overrides that are already parsed into TOML values. pub cli_overrides: Vec<(String, TomlValue)>, /// Loader override knobs used by config API paths. @@ -401,6 +404,7 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { outgoing: Arc::clone(&processor_outgoing), arg0_paths: args.arg0_paths, config: args.config, + thread_manager: args.thread_manager, cli_overrides: args.cli_overrides, loader_overrides: args.loader_overrides, cloud_requirements: args.cloud_requirements, @@ -442,6 +446,18 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { ); if !was_initialized && session.initialized { processor.send_initialize_notifications().await; + for thread_id in processor + .thread_manager() + .list_thread_ids() + .await + { + processor + .try_attach_thread_listener( + thread_id, + vec![IN_PROCESS_CONNECTION_ID], + ) + .await; + } } } Some(ProcessorCommand::Notification(notification)) => { @@ -727,8 +743,13 @@ mod tests { use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; + use codex_app_server_protocol::TurnStartParams; + use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; + use codex_core::AuthManager; + use codex_core::ThreadManager; use codex_core::config::ConfigBuilder; + use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig; use pretty_assertions::assert_eq; async fn build_test_config() -> Config { @@ -746,6 +767,7 @@ mod tests { let args = InProcessStartArgs { arg0_paths: Arg0DispatchPaths::default(), config: Arc::new(build_test_config().await), + thread_manager: None, cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), cloud_requirements: CloudRequirementsLoader::default(), @@ -845,6 +867,270 @@ mod tests { .expect("in-process runtime should shutdown cleanly"); } + #[tokio::test] + async fn in_process_shutdown_restores_shared_auth_refresher() { + let mut config = build_test_config().await; + config.forced_chatgpt_workspace_id = Some("workspace-during".to_string()); + let auth_manager = AuthManager::shared( + config.codex_home.clone(), + false, + config.cli_auth_credentials_store_mode, + ); + auth_manager.set_forced_chatgpt_workspace_id(Some("workspace-before".to_string())); + let thread_manager = Arc::new(ThreadManager::new( + &config, + auth_manager.clone(), + SessionSource::Cli, + CollaborationModesConfig { + default_mode_request_user_input: false, + }, + )); + + assert!(!auth_manager.has_external_auth_refresher()); + assert_eq!( + auth_manager.forced_chatgpt_workspace_id(), + Some("workspace-before".to_string()) + ); + + let args = InProcessStartArgs { + arg0_paths: Arg0DispatchPaths::default(), + config: Arc::new(config), + thread_manager: Some(thread_manager), + cli_overrides: Vec::new(), + loader_overrides: LoaderOverrides::default(), + cloud_requirements: CloudRequirementsLoader::default(), + feedback: CodexFeedback::new(), + config_warnings: Vec::new(), + session_source: SessionSource::Cli, + enable_codex_api_key_env: false, + initialize: InitializeParams { + client_info: ClientInfo { + name: "codex-in-process-test".to_string(), + title: None, + version: "0.0.0".to_string(), + }, + capabilities: None, + }, + channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, + }; + let client = start(args).await.expect("in-process runtime should start"); + assert!(auth_manager.has_external_auth_refresher()); + assert_eq!( + auth_manager.forced_chatgpt_workspace_id(), + Some("workspace-during".to_string()) + ); + + client + .shutdown() + .await + .expect("in-process runtime should shutdown cleanly"); + + assert!(!auth_manager.has_external_auth_refresher()); + assert_eq!( + auth_manager.forced_chatgpt_workspace_id(), + Some("workspace-before".to_string()) + ); + } + + #[tokio::test] + async fn nested_in_process_clients_keep_latest_shared_auth_override_active() { + let mut config_a = build_test_config().await; + config_a.forced_chatgpt_workspace_id = Some("workspace-a".to_string()); + let auth_manager = AuthManager::shared( + config_a.codex_home.clone(), + false, + config_a.cli_auth_credentials_store_mode, + ); + auth_manager.set_forced_chatgpt_workspace_id(Some("workspace-before".to_string())); + let thread_manager = Arc::new(ThreadManager::new( + &config_a, + auth_manager.clone(), + SessionSource::Cli, + CollaborationModesConfig { + default_mode_request_user_input: false, + }, + )); + + let client_a = start(InProcessStartArgs { + arg0_paths: Arg0DispatchPaths::default(), + config: Arc::new(config_a), + thread_manager: Some(Arc::clone(&thread_manager)), + cli_overrides: Vec::new(), + loader_overrides: LoaderOverrides::default(), + cloud_requirements: CloudRequirementsLoader::default(), + feedback: CodexFeedback::new(), + config_warnings: Vec::new(), + session_source: SessionSource::Cli, + enable_codex_api_key_env: false, + initialize: InitializeParams { + client_info: ClientInfo { + name: "codex-in-process-test-a".to_string(), + title: None, + version: "0.0.0".to_string(), + }, + capabilities: None, + }, + channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, + }) + .await + .expect("first in-process runtime should start"); + + assert!(auth_manager.has_external_auth_refresher()); + assert_eq!( + auth_manager.forced_chatgpt_workspace_id(), + Some("workspace-a".to_string()) + ); + + let mut config_b = build_test_config().await; + config_b.forced_chatgpt_workspace_id = Some("workspace-b".to_string()); + let client_b = start(InProcessStartArgs { + arg0_paths: Arg0DispatchPaths::default(), + config: Arc::new(config_b), + thread_manager: Some(Arc::clone(&thread_manager)), + cli_overrides: Vec::new(), + loader_overrides: LoaderOverrides::default(), + cloud_requirements: CloudRequirementsLoader::default(), + feedback: CodexFeedback::new(), + config_warnings: Vec::new(), + session_source: SessionSource::Cli, + enable_codex_api_key_env: false, + initialize: InitializeParams { + client_info: ClientInfo { + name: "codex-in-process-test-b".to_string(), + title: None, + version: "0.0.0".to_string(), + }, + capabilities: None, + }, + channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, + }) + .await + .expect("second in-process runtime should start"); + + assert!(auth_manager.has_external_auth_refresher()); + assert_eq!( + auth_manager.forced_chatgpt_workspace_id(), + Some("workspace-b".to_string()) + ); + + client_a + .shutdown() + .await + .expect("first in-process runtime should shutdown cleanly"); + + assert!(auth_manager.has_external_auth_refresher()); + assert_eq!( + auth_manager.forced_chatgpt_workspace_id(), + Some("workspace-b".to_string()) + ); + + client_b + .shutdown() + .await + .expect("second in-process runtime should shutdown cleanly"); + + assert!(!auth_manager.has_external_auth_refresher()); + assert_eq!( + auth_manager.forced_chatgpt_workspace_id(), + Some("workspace-before".to_string()) + ); + } + + #[tokio::test] + async fn in_process_start_attaches_listeners_for_existing_shared_manager_threads() { + let config = build_test_config().await; + let auth_manager = AuthManager::shared( + config.codex_home.clone(), + false, + config.cli_auth_credentials_store_mode, + ); + let thread_manager = Arc::new(ThreadManager::new( + &config, + auth_manager, + SessionSource::Cli, + CollaborationModesConfig { + default_mode_request_user_input: false, + }, + )); + let new_thread = thread_manager + .start_thread(config.clone()) + .await + .expect("pre-existing shared-manager thread"); + let thread_id = new_thread.thread_id.to_string(); + + let mut client = start(InProcessStartArgs { + arg0_paths: Arg0DispatchPaths::default(), + config: Arc::new(config), + thread_manager: Some(Arc::clone(&thread_manager)), + cli_overrides: Vec::new(), + loader_overrides: LoaderOverrides::default(), + cloud_requirements: CloudRequirementsLoader::default(), + feedback: CodexFeedback::new(), + config_warnings: Vec::new(), + session_source: SessionSource::Cli, + enable_codex_api_key_env: false, + initialize: InitializeParams { + client_info: ClientInfo { + name: "codex-in-process-existing-thread-test".to_string(), + title: None, + version: "0.0.0".to_string(), + }, + capabilities: None, + }, + channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, + }) + .await + .expect("in-process runtime should start"); + + let response = client + .request(ClientRequest::TurnStart { + request_id: RequestId::Integer(100), + params: TurnStartParams { + thread_id: thread_id.clone(), + input: vec![codex_app_server_protocol::UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: None, + service_tier: None, + effort: None, + summary: None, + personality: None, + output_schema: None, + collaboration_mode: None, + }, + }) + .await + .expect("turn/start request should be sent") + .expect("turn/start should succeed"); + let _: TurnStartResponse = + serde_json::from_value(response).expect("turn/start response should parse"); + + let started = timeout(Duration::from_secs(2), async { + loop { + let Some(event) = client.next_event().await else { + panic!("expected in-process server event"); + }; + if let InProcessServerEvent::LegacyNotification(notification) = event + && notification.method == "codex/event/task_started" + { + break notification; + } + } + }) + .await + .expect("timed out waiting for task_started notification"); + assert_eq!(started.method, "codex/event/task_started".to_string()); + + client + .shutdown() + .await + .expect("in-process runtime should shutdown cleanly"); + } + #[test] fn guaranteed_delivery_helpers_cover_terminal_notifications() { assert!(server_notification_requires_delivery( diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index ffd8eecbf79..96ea78079b4 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -596,6 +596,7 @@ pub async fn run_main_with_transport( outgoing: outgoing_message_sender, arg0_paths, config: Arc::new(config), + thread_manager: None, cli_overrides, loader_overrides, cloud_requirements: cloud_requirements.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 3b85cc77cc4..e6b6b060c38 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -41,6 +41,7 @@ use codex_app_server_protocol::experimental_required_message; use codex_arg0::Arg0DispatchPaths; use codex_core::AuthManager; use codex_core::ThreadManager; +use codex_core::auth::ExternalAuthOverrideGuard; use codex_core::auth::ExternalAuthRefreshContext; use codex_core::auth::ExternalAuthRefreshReason; use codex_core::auth::ExternalAuthRefresher; @@ -140,6 +141,7 @@ pub(crate) struct MessageProcessor { external_agent_config_api: ExternalAgentConfigApi, config: Arc, config_warnings: Arc>, + _external_auth_override_guard: ExternalAuthOverrideGuard, } #[derive(Clone, Debug, Default)] @@ -155,6 +157,7 @@ pub(crate) struct MessageProcessorArgs { pub(crate) outgoing: Arc, pub(crate) arg0_paths: Arg0DispatchPaths, pub(crate) config: Arc, + pub(crate) thread_manager: Option>, pub(crate) cli_overrides: Vec<(String, TomlValue)>, pub(crate) loader_overrides: LoaderOverrides, pub(crate) cloud_requirements: CloudRequirementsLoader, @@ -173,6 +176,7 @@ impl MessageProcessor { outgoing, arg0_paths, config, + thread_manager, cli_overrides, loader_overrides, cloud_requirements, @@ -182,25 +186,34 @@ impl MessageProcessor { session_source, enable_codex_api_key_env, } = args; - let auth_manager = AuthManager::shared( - config.codex_home.clone(), - enable_codex_api_key_env, - config.cli_auth_credentials_store_mode, - ); - auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); - auth_manager.set_external_auth_refresher(Arc::new(ExternalAuthRefreshBridge { - outgoing: outgoing.clone(), - })); - let thread_manager = Arc::new(ThreadManager::new( - config.as_ref(), - auth_manager.clone(), - session_source, - CollaborationModesConfig { - default_mode_request_user_input: config - .features - .enabled(codex_core::features::Feature::DefaultModeRequestUserInput), + let auth_manager = thread_manager.as_ref().map_or_else( + || { + AuthManager::shared( + config.codex_home.clone(), + enable_codex_api_key_env, + config.cli_auth_credentials_store_mode, + ) }, - )); + |thread_manager| thread_manager.auth_manager(), + ); + let external_auth_override_guard = auth_manager.push_external_auth_override( + Arc::new(ExternalAuthRefreshBridge { + outgoing: outgoing.clone(), + }), + config.forced_chatgpt_workspace_id.clone(), + ); + let thread_manager = thread_manager.unwrap_or_else(|| { + Arc::new(ThreadManager::new( + config.as_ref(), + auth_manager.clone(), + session_source, + CollaborationModesConfig { + default_mode_request_user_input: config + .features + .enabled(codex_core::features::Feature::DefaultModeRequestUserInput), + }, + )) + }); // TODO(xl): Move into PluginManager once this no longer depends on config feature gating. thread_manager .plugins_manager() @@ -233,6 +246,7 @@ impl MessageProcessor { external_agent_config_api, config, config_warnings: Arc::new(config_warnings), + _external_auth_override_guard: external_auth_override_guard, } } @@ -417,6 +431,10 @@ impl MessageProcessor { .await; } + pub(crate) fn thread_manager(&self) -> &Arc { + self.codex_message_processor.thread_manager() + } + pub(crate) async fn drain_background_tasks(&self) { self.codex_message_processor.drain_background_tasks().await; } diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index 622aa40230b..b8229ae9338 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -231,6 +231,7 @@ fn build_test_processor( outgoing, arg0_paths: Arg0DispatchPaths::default(), config, + thread_manager: None, cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), cloud_requirements: CloudRequirementsLoader::default(), @@ -511,13 +512,15 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { ) .await; let spans = wait_for_exported_spans(harness.tracing, |spans| { - spans - .iter() - .any(|span| span.name.as_ref() == "submission_dispatch") - && spans - .iter() - .any(|span| span.name.as_ref() == "session_task.turn") - && spans.iter().any(|span| span.name.as_ref() == "run_turn") + spans.iter().any(|span| { + span.name.as_ref() == "submission_dispatch" + && span.span_context.trace_id() == remote_trace_id + }) && spans.iter().any(|span| { + span.name.as_ref() == "session_task.turn" + && span.span_context.trace_id() == remote_trace_id + }) && spans.iter().any(|span| { + span.name.as_ref() == "run_turn" && span.span_context.trace_id() == remote_trace_id + }) }) .await; drop(harness.processor); diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index 8bb2b23d876..57761e3e92f 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -14,6 +14,8 @@ use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; use std::sync::RwLock; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; use codex_app_server_protocol::AuthMode as ApiAuthMode; use codex_otel::TelemetryAuthMode; @@ -832,6 +834,32 @@ impl Debug for CachedAuth { } } +#[derive(Clone)] +/// One entry in the auth override stack managed by [`AuthManager`]. +/// +/// Each in-process app-server session pushes an entry when it starts and +/// removes it (via [`ExternalAuthOverrideGuard`]) when it shuts down. The +/// most recent entry wins: its `refresher` is used for token refresh and its +/// `forced_workspace_id` constrains workspace selection. A `None` workspace +/// means "inherit from the next entry down the stack (or the base setting)." +struct ExternalAuthOverrideEntry { + /// Unique identifier used by the guard to remove this specific entry. + id: u64, + refresher: Arc, + /// Workspace restriction for this override scope, or `None` to inherit. + forced_workspace_id: Option, +} + +impl Debug for ExternalAuthOverrideEntry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ExternalAuthOverrideEntry") + .field("id", &self.id) + .field("refresher", &"present") + .field("forced_workspace_id", &self.forced_workspace_id) + .finish() + } +} + enum UnauthorizedRecoveryStep { Reload, RefreshToken, @@ -968,12 +996,49 @@ impl UnauthorizedRecovery { /// `reload()` is called explicitly. This matches the design goal of avoiding /// different parts of the program seeing inconsistent auth data mid‑run. #[derive(Debug)] +/// Central authority for authentication credentials and token lifecycle. +/// +/// `AuthManager` loads, caches, and refreshes auth credentials (API key or +/// ChatGPT OAuth tokens) and distributes them to threads and app-server +/// sessions. It is shared via `Arc` and is safe to call from any thread. +/// +/// In-process app-server sessions may temporarily redirect token refresh by +/// pushing an [`ExternalAuthOverrideEntry`] onto the override stack. The +/// stack uses RAII guards ([`ExternalAuthOverrideGuard`]) so cleanup is +/// automatic. See [`push_external_auth_override`](Self::push_external_auth_override) +/// for the nesting contract. pub struct AuthManager { codex_home: PathBuf, + /// Cached auth credentials; refreshed lazily on access. inner: RwLock, enable_codex_api_key_env: bool, auth_credentials_store_mode: AuthCredentialsStoreMode, + /// Base workspace restriction, independent of any override. forced_chatgpt_workspace_id: RwLock>, + /// Stack of active auth overrides, most recent last. + external_auth_overrides: RwLock>, + next_external_auth_override_id: AtomicU64, +} + +/// RAII guard that removes an auth override from the [`AuthManager`] stack on drop. +/// +/// Returned by [`AuthManager::push_external_auth_override`]. The guard holds +/// an `Arc` reference, so the manager stays alive at least as +/// long as the guard. Dropping the guard removes exactly the entry with the +/// matching `id`, even if other entries were pushed or removed in the interim. +/// +/// Callers that need nested auth scopes (e.g. a child agent spawned inside an +/// in-process session) simply hold multiple guards; the stack unwinds in drop +/// order. +pub struct ExternalAuthOverrideGuard { + auth_manager: Arc, + id: u64, +} + +impl Drop for ExternalAuthOverrideGuard { + fn drop(&mut self) { + self.auth_manager.remove_external_auth_override(self.id); + } } impl AuthManager { @@ -1002,6 +1067,8 @@ impl AuthManager { enable_codex_api_key_env, auth_credentials_store_mode, forced_chatgpt_workspace_id: RwLock::new(None), + external_auth_overrides: RwLock::new(Vec::new()), + next_external_auth_override_id: AtomicU64::new(1), } } @@ -1018,6 +1085,8 @@ impl AuthManager { enable_codex_api_key_env: false, auth_credentials_store_mode: AuthCredentialsStoreMode::File, forced_chatgpt_workspace_id: RwLock::new(None), + external_auth_overrides: RwLock::new(Vec::new()), + next_external_auth_override_id: AtomicU64::new(1), }) } @@ -1036,6 +1105,8 @@ impl AuthManager { enable_codex_api_key_env: false, auth_credentials_store_mode: AuthCredentialsStoreMode::File, forced_chatgpt_workspace_id: RwLock::new(None), + external_auth_overrides: RwLock::new(Vec::new()), + next_external_auth_override_id: AtomicU64::new(1), }) } @@ -1044,6 +1115,60 @@ impl AuthManager { self.inner.read().ok().and_then(|c| c.auth.clone()) } + fn current_external_auth_override(&self) -> Option> { + self.external_auth_overrides + .read() + .ok() + .and_then(|guard| guard.last().map(|entry| entry.refresher.clone())) + } + + fn base_forced_chatgpt_workspace_id(&self) -> Option { + self.forced_chatgpt_workspace_id + .read() + .ok() + .and_then(|guard| guard.clone()) + } + + fn remove_external_auth_override(&self, id: u64) { + if let Ok(mut guard) = self.external_auth_overrides.write() + && let Some(index) = guard.iter().rposition(|entry| entry.id == id) + { + guard.remove(index); + } + } + + /// Pushes an auth override onto the stack and returns an RAII guard. + /// + /// While the guard is alive, `refresh_external_auth` and + /// `forced_chatgpt_workspace_id` consult this entry (if it is still the + /// topmost). Dropping the guard removes the entry regardless of stack + /// position. + /// + /// Pass `forced_workspace_id: None` to inherit the workspace restriction + /// from the next entry down the stack (or the base setting). This is the + /// common case for child agents that should run in the same workspace as + /// their parent session. + pub fn push_external_auth_override( + self: &Arc, + refresher: Arc, + forced_workspace_id: Option, + ) -> ExternalAuthOverrideGuard { + let id = self + .next_external_auth_override_id + .fetch_add(1, Ordering::Relaxed); + if let Ok(mut guard) = self.external_auth_overrides.write() { + guard.push(ExternalAuthOverrideEntry { + id, + refresher, + forced_workspace_id, + }); + } + ExternalAuthOverrideGuard { + auth_manager: Arc::clone(self), + id, + } + } + /// Current cached auth (clone). May be `None` if not logged in or load failed. /// Refreshes cached ChatGPT tokens if they are stale before returning. pub async fn auth(&self) -> Option { @@ -1140,26 +1265,50 @@ impl AuthManager { } } + pub fn replace_external_auth_refresher( + &self, + refresher: Option>, + ) -> Option> { + self.inner + .write() + .ok() + .and_then(|mut guard| std::mem::replace(&mut guard.external_refresher, refresher)) + } + pub fn set_external_auth_refresher(&self, refresher: Arc) { - if let Ok(mut guard) = self.inner.write() { - guard.external_refresher = Some(refresher); - } + let _ = self.replace_external_auth_refresher(Some(refresher)); + } + + pub fn replace_forced_chatgpt_workspace_id( + &self, + workspace_id: Option, + ) -> Option { + self.forced_chatgpt_workspace_id + .write() + .ok() + .and_then(|mut guard| std::mem::replace(&mut guard, workspace_id)) } pub fn set_forced_chatgpt_workspace_id(&self, workspace_id: Option) { - if let Ok(mut guard) = self.forced_chatgpt_workspace_id.write() { - *guard = workspace_id; - } + let _ = self.replace_forced_chatgpt_workspace_id(workspace_id); } pub fn forced_chatgpt_workspace_id(&self) -> Option { - self.forced_chatgpt_workspace_id - .read() - .ok() - .and_then(|guard| guard.clone()) + if let Ok(guard) = self.external_auth_overrides.read() + && let Some(workspace_id) = guard + .iter() + .rev() + .find_map(|entry| entry.forced_workspace_id.clone()) + { + return Some(workspace_id); + } + self.base_forced_chatgpt_workspace_id() } pub fn has_external_auth_refresher(&self) -> bool { + if self.current_external_auth_override().is_some() { + return true; + } self.inner .read() .ok() @@ -1295,15 +1444,20 @@ impl AuthManager { &self, reason: ExternalAuthRefreshReason, ) -> Result<(), RefreshTokenError> { - let forced_chatgpt_workspace_id = self.forced_chatgpt_workspace_id(); - let refresher = match self.inner.read() { - Ok(guard) => guard.external_refresher.clone(), - Err(_) => { - return Err(RefreshTokenError::Transient(std::io::Error::other( - "failed to read external auth state", - ))); - } - }; + let (refresher, forced_chatgpt_workspace_id) = + if let Some(refresher) = self.current_external_auth_override() { + (Some(refresher), self.forced_chatgpt_workspace_id()) + } else { + let refresher = match self.inner.read() { + Ok(guard) => guard.external_refresher.clone(), + Err(_) => { + return Err(RefreshTokenError::Transient(std::io::Error::other( + "failed to read external auth state", + ))); + } + }; + (refresher, self.forced_chatgpt_workspace_id()) + }; let Some(refresher) = refresher else { return Err(RefreshTokenError::Transient(std::io::Error::other( diff --git a/codex-rs/core/src/auth_tests.rs b/codex-rs/core/src/auth_tests.rs index 0c4a574f340..f28dabc6327 100644 --- a/codex-rs/core/src/auth_tests.rs +++ b/codex-rs/core/src/auth_tests.rs @@ -13,8 +13,21 @@ use codex_protocol::config_types::ForcedLoginMethod; use pretty_assertions::assert_eq; use serde::Serialize; use serde_json::json; +use std::sync::Arc; use tempfile::tempdir; +struct NoopExternalAuthRefresher; + +#[async_trait::async_trait] +impl ExternalAuthRefresher for NoopExternalAuthRefresher { + async fn refresh( + &self, + _context: ExternalAuthRefreshContext, + ) -> std::io::Result { + unreachable!("test refresher should never be called") + } +} + #[tokio::test] async fn refresh_without_id_token() { let codex_home = tempdir().unwrap(); @@ -430,3 +443,38 @@ fn missing_plan_type_maps_to_unknown() { pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Unknown)); } + +#[test] +fn nested_external_auth_override_inherits_lower_workspace_when_unset() { + let codex_home = tempdir().unwrap(); + let auth_manager = AuthManager::shared( + codex_home.path().to_path_buf(), + false, + AuthCredentialsStoreMode::File, + ); + auth_manager.set_forced_chatgpt_workspace_id(Some("workspace-base".to_string())); + + let outer_guard = auth_manager.push_external_auth_override( + Arc::new(NoopExternalAuthRefresher), + Some("workspace-outer".to_string()), + ); + let inner_guard = + auth_manager.push_external_auth_override(Arc::new(NoopExternalAuthRefresher), None); + + assert_eq!( + auth_manager.forced_chatgpt_workspace_id(), + Some("workspace-outer".to_string()) + ); + + drop(inner_guard); + assert_eq!( + auth_manager.forced_chatgpt_workspace_id(), + Some("workspace-outer".to_string()) + ); + + drop(outer_guard); + assert_eq!( + auth_manager.forced_chatgpt_workspace_id(), + Some("workspace-base".to_string()) + ); +} diff --git a/codex-rs/core/src/seatbelt_tests.rs b/codex-rs/core/src/seatbelt_tests.rs index 9ac5eaa7b02..943d2392362 100644 --- a/codex-rs/core/src/seatbelt_tests.rs +++ b/codex-rs/core/src/seatbelt_tests.rs @@ -32,8 +32,13 @@ use tempfile::TempDir; fn assert_seatbelt_denied(stderr: &[u8], path: &Path) { let stderr = String::from_utf8_lossy(stderr); let expected = format!("bash: {}: Operation not permitted\n", path.display()); + let expected_with_line = format!( + "bash: line 1: {}: Operation not permitted\n", + path.display() + ); assert!( stderr == expected + || stderr == expected_with_line || stderr.contains("sandbox-exec: sandbox_apply: Operation not permitted"), "unexpected stderr: {stderr}" ); diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 3ed8e8f0b38..b55de6561e7 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -501,14 +501,28 @@ impl ThreadManager { AgentControl::new(Arc::downgrade(&self.state)) } - #[cfg(test)] - pub(crate) fn captured_ops(&self) -> Vec<(ThreadId, Op)> { + /// Send an operation to an existing thread managed by this manager. + pub async fn send_op(&self, thread_id: ThreadId, op: Op) -> CodexResult { + self.state.send_op(thread_id, op).await + } + + pub fn auth_manager(&self) -> Arc { + Arc::clone(&self.state.auth_manager) + } + + #[doc(hidden)] + pub fn captured_ops_for_testing(&self) -> Vec<(ThreadId, Op)> { self.state .ops_log .as_ref() .and_then(|ops_log| ops_log.lock().ok().map(|log| log.clone())) .unwrap_or_default() } + + #[cfg(test)] + pub(crate) fn captured_ops(&self) -> Vec<(ThreadId, Op)> { + self.captured_ops_for_testing() + } } impl ThreadManagerState { diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index d0a6ac2c3e7..6a70fa0a820 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -430,6 +430,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result let in_process_start_args = InProcessClientStartArgs { arg0_paths, config: std::sync::Arc::new(config.clone()), + thread_manager: None, cli_overrides: run_cli_overrides, loader_overrides: run_loader_overrides, cloud_requirements: run_cloud_requirements, diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 594acb33d46..1ff58a2d7f1 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -29,6 +29,7 @@ base64 = { workspace = true } chrono = { workspace = true, features = ["serde"] } clap = { workspace = true, features = ["derive"] } codex-ansi-escape = { workspace = true } +codex-app-server-client = { workspace = true } codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } codex-backend-client = { workspace = true } @@ -134,6 +135,7 @@ codex-core = { workspace = true } codex-utils-cargo-bin = { workspace = true } codex-utils-pty = { workspace = true } assert_matches = { workspace = true } +async-trait = { workspace = true } chrono = { workspace = true, features = ["serde"] } insta = { workspace = true } pretty_assertions = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 87b2dc795c0..4fd88097511 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -13,7 +13,9 @@ use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::chatwidget::ChatWidget; use crate::chatwidget::ExternalEditorState; +use crate::chatwidget::InProcessAgentContext; use crate::chatwidget::ThreadInputState; +use crate::chatwidget::ThreadScopedOp; use crate::cwd_prompt::CwdPromptAction; use crate::diff_render::DiffSummary; use crate::exec_command::strip_bash_lc_and_escape; @@ -40,6 +42,7 @@ use crate::update_action::UpdateAction; use crate::version::CODEX_CLI_VERSION; use codex_ansi_escape::ansi_escape_line; use codex_app_server_protocol::ConfigLayerSource; +use codex_arg0::Arg0DispatchPaths; use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::ThreadManager; @@ -49,6 +52,7 @@ use codex_core::config::ConfigOverrides; use codex_core::config::edit::ConfigEdit; use codex_core::config::edit::ConfigEditsBuilder; use codex_core::config::types::ModelAvailabilityNuxConfig; +use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::ConfigLayerStackOrdering; use codex_core::features::Feature; use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig; @@ -78,6 +82,7 @@ use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillErrorInfo; +use codex_protocol::protocol::ThreadRolledBackEvent; use codex_protocol::protocol::TokenUsage; use codex_utils_absolute_path::AbsolutePathBuf; use color_eyre::eyre::Result; @@ -117,13 +122,21 @@ mod pending_interactive_replay; use self::agent_navigation::AgentNavigationDirection; use self::agent_navigation::AgentNavigationState; use self::pending_interactive_replay::PendingInteractiveReplayState; +use crate::bottom_pane::ThreadUserInputRequest; const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue."; const THREAD_EVENT_CHANNEL_CAPACITY: usize = 32768; +/// An interactive prompt extracted from an inactive thread's event stream. +/// +/// When a background thread emits an approval, elicitation, or user-input +/// request while it is not the active thread, `App` wraps the event in this +/// enum and pushes it to the `ChatWidget` so the user can respond without +/// switching threads. enum ThreadInteractiveRequest { Approval(ApprovalRequest), McpServerElicitation(McpServerElicitationFormRequest), + UserInput(ThreadUserInputRequest), } /// Baseline cadence for periodic stream commit animation ticks. /// @@ -263,18 +276,38 @@ struct SessionSummary { #[derive(Debug, Clone)] struct ThreadEventSnapshot { session_configured: Option, - events: Vec, + replay_timeline: Vec, input_state: Option, } +#[derive(Debug, Clone)] +enum ThreadReplayItem { + Event(Box), + HistoryCell(Arc), +} + +/// Per-thread event buffer that records the replay timeline for thread switching. +/// +/// Each thread gets its own store. When the user switches to a thread, the +/// store's [`snapshot`](Self::snapshot) is replayed into a fresh `ChatWidget` +/// to reconstruct the conversation view. Interactive prompts that have already +/// been resolved are filtered out by [`PendingInteractiveReplayState`] so +/// answered approvals do not reappear. +/// +/// The `active` flag tracks whether this store's channel receiver is currently +/// owned by the `App`'s `select!` loop. Only active stores forward events +/// through the channel; inactive stores buffer silently. #[derive(Debug)] struct ThreadEventStore { session_configured: Option, - buffer: VecDeque, + replay_timeline: VecDeque, + /// Deduplication set for `UserMessage` events by event ID. user_message_ids: HashSet, pending_interactive_replay: PendingInteractiveReplayState, input_state: Option, + /// Maximum number of replay items before oldest are evicted. capacity: usize, + /// Whether this thread's channel receiver is currently in the active select loop. active: bool, } @@ -282,7 +315,7 @@ impl ThreadEventStore { fn new(capacity: usize) -> Self { Self { session_configured: None, - buffer: VecDeque::new(), + replay_timeline: VecDeque::new(), user_message_ids: HashSet::new(), pending_interactive_replay: PendingInteractiveReplayState::default(), input_state: None, @@ -330,39 +363,74 @@ impl ThreadEventStore { { return; } - self.buffer.push_back(event); - if self.buffer.len() > self.capacity - && let Some(removed) = self.buffer.pop_front() - { - self.pending_interactive_replay.note_evicted_event(&removed); - if matches!(removed.msg, EventMsg::UserMessage(_)) && !removed.id.is_empty() { - self.user_message_ids.remove(&removed.id); - } - } + self.replay_timeline + .push_back(ThreadReplayItem::Event(Box::new(event))); + self.evict_oldest_replay_item(); } fn snapshot(&self) -> ThreadEventSnapshot { ThreadEventSnapshot { session_configured: self.session_configured.clone(), - // Thread switches replay buffered events into a rebuilt ChatWidget. Only replay - // interactive prompts that are still pending, or answered approvals/input will reappear. - events: self - .buffer + // Thread switches replay a rebuilt ChatWidget in app-loop order. Only replay + // interactive prompt events that are still pending, or answered approvals/input will + // reappear on thread switches. + replay_timeline: self + .replay_timeline .iter() - .filter(|event| { - self.pending_interactive_replay + .filter_map(|item| match item { + ThreadReplayItem::Event(event) => self + .pending_interactive_replay .should_replay_snapshot_event(event) + .then(|| ThreadReplayItem::Event(Box::new((**event).clone()))), + ThreadReplayItem::HistoryCell(cell) => { + Some(ThreadReplayItem::HistoryCell(cell.clone())) + } }) - .cloned() .collect(), input_state: self.input_state.clone(), } } + fn push_thread_history_cell(&mut self, cell: Arc) { + self.replay_timeline + .push_back(ThreadReplayItem::HistoryCell(cell)); + self.evict_oldest_replay_item(); + } + fn note_outbound_op(&mut self, op: &Op) { self.pending_interactive_replay.note_outbound_op(op); } + fn current_turn_id(&self) -> Option { + for item in self.replay_timeline.iter().rev() { + if let ThreadReplayItem::Event(event) = item { + match &event.msg { + EventMsg::TurnStarted(event) => return Some(event.turn_id.clone()), + EventMsg::TurnComplete(_) + | EventMsg::TurnAborted(_) + | EventMsg::ShutdownComplete => return None, + _ => {} + } + } + } + None + } + + fn evict_oldest_replay_item(&mut self) { + while self.replay_timeline.len() > self.capacity { + let Some(removed) = self.replay_timeline.pop_front() else { + break; + }; + let ThreadReplayItem::Event(event) = removed else { + continue; + }; + self.pending_interactive_replay.note_evicted_event(&event); + if matches!(event.msg, EventMsg::UserMessage(_)) && !event.id.is_empty() { + self.user_message_ids.remove(&event.id); + } + } + } + fn op_can_change_pending_replay_state(op: &Op) -> bool { PendingInteractiveReplayState::op_can_change_state(op) } @@ -377,6 +445,13 @@ impl ThreadEventStore { } } +/// Channel triple (sender, receiver, store) for one thread's event stream. +/// +/// The receiver is `Option` because it is *taken* when the thread becomes +/// active (moved into `App::active_thread_rx` for the `select!` loop) and +/// *returned* when the thread becomes inactive. While the receiver is taken, +/// the store's `active` flag is `true` and events flow through the channel. +/// While inactive, events are buffered in the store only. #[derive(Debug)] struct ThreadEventChannel { sender: mpsc::Sender, @@ -647,6 +722,12 @@ pub(crate) struct App { /// Config is stored here so we can recreate ChatWidgets as needed. pub(crate) config: Config, pub(crate) active_profile: Option, + /// Binary dispatch paths used to locate the codex executable and related + /// tools. Passed through to each agent's `InProcessAgentContext`. + arg0_paths: Arg0DispatchPaths, + /// Cloud-requirement loader shared with the in-process app-server client so + /// it can resolve cloud-specific config constraints at session start. + cloud_requirements: CloudRequirementsLoader, cli_kv_overrides: Vec<(String, TomlValue)>, harness_overrides: ConfigOverrides, runtime_approval_policy_override: Option, @@ -660,6 +741,7 @@ pub(crate) struct App { pub(crate) overlay: Option, pub(crate) deferred_history_lines: Vec>, has_emitted_history_lines: bool, + pending_replay_rollbacks_to_ignore: u32, pub(crate) enhanced_keys_supported: bool, @@ -700,6 +782,11 @@ pub(crate) struct App { thread_event_channels: HashMap, thread_event_listener_tasks: HashMap>, + /// When `true`, the primary session's events arrive through the in-process + /// app-server event stream, so `register_live_thread` must *not* spawn a + /// competing `next_event()` listener for the primary thread. + active_session_events_via_app_server: bool, + primary_app_server_op_tx: Option>, agent_navigation: AgentNavigationState, active_thread_id: Option, active_thread_rx: Option>, @@ -754,6 +841,11 @@ impl App { startup_tooltip_override: None, status_line_invalid_items_warned: self.status_line_invalid_items_warned.clone(), session_telemetry: self.session_telemetry.clone(), + in_process_context: InProcessAgentContext { + arg0_paths: self.arg0_paths.clone(), + cli_kv_overrides: self.cli_kv_overrides.clone(), + cloud_requirements: self.cloud_requirements.clone(), + }, } } @@ -987,8 +1079,25 @@ impl App { // Clear any in-flight rollback guard when switching threads. self.backtrack.pending_rollback = None; self.suppress_shutdown_complete = true; - self.chat_widget.submit_op(Op::Shutdown); - self.server.remove_thread(&thread_id).await; + if self.active_session_events_via_app_server { + match self.server.remove_thread(&thread_id).await { + Some(thread) => { + if let Err(err) = thread.submit(Op::Shutdown).await { + tracing::error!( + "failed to submit shutdown for switched thread {thread_id}: {err}" + ); + } + } + None => { + tracing::warn!( + "failed to remove switched thread {thread_id} before shutdown" + ); + } + } + } else { + self.chat_widget.submit_op(Op::Shutdown); + self.server.remove_thread(&thread_id).await; + } self.abort_thread_event_listener(thread_id); } } @@ -1150,6 +1259,12 @@ impl App { } } + async fn current_turn_id_for_thread(&self, thread_id: ThreadId) -> Option { + let channel = self.thread_event_channels.get(&thread_id)?; + let store = channel.store.lock().await; + store.current_turn_id() + } + async fn interactive_request_for_thread_event( &self, thread_id: ThreadId, @@ -1208,6 +1323,12 @@ impl App { permissions: ev.permissions.clone(), }, )), + EventMsg::RequestUserInput(ev) => Some(ThreadInteractiveRequest::UserInput( + ThreadUserInputRequest { + thread_id, + request: ev.clone(), + }, + )), _ => None, } } @@ -1215,7 +1336,36 @@ impl App { async fn submit_op_to_thread(&mut self, thread_id: ThreadId, op: Op) { let replay_state_op = ThreadEventStore::op_can_change_pending_replay_state(&op).then(|| op.clone()); - let submitted = if self.active_thread_id == Some(thread_id) { + let submitted = if self.should_submit_thread_op_via_app_server(&op) { + crate::session_log::log_outbound_op(&op); + match self.primary_app_server_op_tx.as_ref() { + Some(tx) => { + let interrupt_turn_id = if matches!(&op, Op::Interrupt) { + self.current_turn_id_for_thread(thread_id).await + } else { + None + }; + if let Err(err) = tx.send(ThreadScopedOp { + thread_id, + op, + interrupt_turn_id, + }) { + self.chat_widget.add_error_message(format!( + "Failed to submit op through app-server session for thread {thread_id}: {err}" + )); + false + } else { + true + } + } + None => { + self.chat_widget.add_error_message(format!( + "No app-server session is available to resolve thread {thread_id} prompt" + )); + false + } + } + } else if self.active_thread_id == Some(thread_id) { self.chat_widget.submit_op(op) } else { crate::session_log::log_outbound_op(&op); @@ -1243,6 +1393,20 @@ impl App { } } + fn should_submit_thread_op_via_app_server(&self, op: &Op) -> bool { + self.active_session_events_via_app_server + && self.primary_app_server_op_tx.is_some() + && matches!( + op, + Op::ExecApproval { .. } + | Op::PatchApproval { .. } + | Op::Interrupt + | Op::ResolveElicitation { .. } + | Op::RequestPermissionsResponse { .. } + | Op::UserInputAnswer { .. } + ) + } + async fn refresh_pending_thread_approvals(&mut self) { let channels: Vec<(ThreadId, Arc>)> = self .thread_event_channels @@ -1275,6 +1439,11 @@ impl App { async fn enqueue_thread_event(&mut self, thread_id: ThreadId, event: Event) -> Result<()> { let refresh_pending_thread_approvals = ThreadEventStore::event_can_change_pending_thread_approvals(&event); + let finished_inactive_thread = self.active_thread_id != Some(thread_id) + && matches!( + event.msg, + EventMsg::TurnComplete(_) | EventMsg::TurnAborted(_) + ); let inactive_interactive_request = if self.active_thread_id != Some(thread_id) { self.interactive_request_for_thread_event(thread_id, &event) .await @@ -1318,8 +1487,14 @@ impl App { self.chat_widget .push_mcp_server_elicitation_request(request); } + ThreadInteractiveRequest::UserInput(request) => { + self.chat_widget.push_user_input_request(request); + } } } + if finished_inactive_thread { + self.chat_widget.dismiss_finished_thread_views(thread_id); + } if refresh_pending_thread_approvals { self.refresh_pending_thread_approvals().await; } @@ -1332,8 +1507,22 @@ impl App { event: Event, ) -> Result<()> { if !self.thread_event_channels.contains_key(&thread_id) { - tracing::debug!("dropping stale event for untracked thread {thread_id}"); - return Ok(()); + if let EventMsg::SessionConfigured(session_configured) = &event.msg { + self.register_replay_only_thread(session_configured.clone(), false) + .await; + } else { + self.handle_thread_created(thread_id).await?; + } + if !self.thread_event_channels.contains_key(&thread_id) { + tracing::debug!("dropping stale event for untracked thread {thread_id}"); + return Ok(()); + } + } + + if matches!(event.msg, EventMsg::ShutdownComplete) + && Some(thread_id) != self.primary_thread_id + { + self.mark_agent_picker_thread_closed(thread_id); } self.enqueue_thread_event(thread_id, event).await @@ -1507,13 +1696,27 @@ impl App { self.active_thread_rx = Some(receiver); let init = self.chatwidget_init_for_forked_or_resumed_thread(tui, self.config.clone()); - let codex_op_tx = if let Some(thread) = live_thread { - crate::chatwidget::spawn_op_forwarder(thread) - } else { - let (tx, _rx) = unbounded_channel(); - tx - }; - self.chat_widget = ChatWidget::new_with_op_sender(init, codex_op_tx); + let (codex_op_tx, thread_scoped_op_tx, app_server_thread_id) = + if let Some(thread) = live_thread { + if self.active_session_events_via_app_server + && self.primary_thread_id == Some(thread_id) + && let Some(thread_scoped_op_tx) = self.primary_app_server_op_tx.clone() + { + let (tx, _rx) = unbounded_channel(); + (tx, Some(thread_scoped_op_tx), Some(thread_id)) + } else { + (crate::chatwidget::spawn_op_forwarder(thread), None, None) + } + } else { + let (tx, _rx) = unbounded_channel(); + (tx, None, None) + }; + self.chat_widget = ChatWidget::new_with_op_sender( + init, + codex_op_tx, + thread_scoped_op_tx, + app_server_thread_id, + ); self.sync_active_agent_label(); self.reset_for_thread_switch(tui)?; @@ -1550,6 +1753,7 @@ impl App { self.active_thread_rx = None; self.primary_thread_id = None; self.pending_primary_events.clear(); + self.primary_app_server_op_tx = None; self.chat_widget.set_pending_thread_approvals(Vec::new()); self.sync_active_agent_label(); } @@ -1594,9 +1798,16 @@ impl App { startup_tooltip_override: None, status_line_invalid_items_warned: self.status_line_invalid_items_warned.clone(), session_telemetry: self.session_telemetry.clone(), + in_process_context: InProcessAgentContext { + arg0_paths: self.arg0_paths.clone(), + cli_kv_overrides: self.cli_kv_overrides.clone(), + cloud_requirements: self.cloud_requirements.clone(), + }, }; self.chat_widget = ChatWidget::new(init, self.server.clone()); + self.active_session_events_via_app_server = true; self.reset_thread_event_state(); + self.primary_app_server_op_tx = self.chat_widget.thread_scoped_op_sender(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; if let Some(command) = summary.resume_command { @@ -1677,8 +1888,11 @@ impl App { self.chat_widget.set_queue_autosend_suppressed(true); self.chat_widget .restore_thread_input_state(snapshot.input_state); - for event in snapshot.events { - self.handle_codex_event_replay(event); + for item in snapshot.replay_timeline { + match item { + ThreadReplayItem::Event(event) => self.handle_codex_event_replay(*event), + ThreadReplayItem::HistoryCell(cell) => self.insert_replayed_history_cell(cell), + } } self.chat_widget.set_queue_autosend_suppressed(false); if resume_restored_queue { @@ -1687,6 +1901,47 @@ impl App { self.refresh_status_line(); } + fn insert_history_cell(&mut self, tui: &mut tui::Tui, cell: Arc) { + if let Some(Overlay::Transcript(t)) = &mut self.overlay { + t.insert_cell(cell.clone()); + tui.frame_requester().schedule_frame(); + } + self.transcript_cells.push(cell.clone()); + let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width); + if !display.is_empty() { + if !cell.is_stream_continuation() { + if self.has_emitted_history_lines { + display.insert(0, Line::from("")); + } else { + self.has_emitted_history_lines = true; + } + } + if self.overlay.is_some() { + self.deferred_history_lines.extend(display); + } else { + tui.insert_history_lines(display); + } + } + } + + fn insert_replayed_history_cell(&mut self, cell: Arc) { + if let Some(Overlay::Transcript(t)) = &mut self.overlay { + t.insert_cell(cell.clone()); + } + self.transcript_cells.push(cell.clone()); + let mut display = cell.display_lines(u16::MAX); + if !display.is_empty() { + if !cell.is_stream_continuation() { + if self.has_emitted_history_lines { + display.insert(0, Line::from("")); + } else { + self.has_emitted_history_lines = true; + } + } + self.deferred_history_lines.extend(display); + } + } + fn should_wait_for_initial_session(session_selection: &SessionSelection) -> bool { matches!( session_selection, @@ -1713,9 +1968,11 @@ impl App { tui: &mut tui::Tui, auth_manager: Arc, mut config: Config, + arg0_paths: Arg0DispatchPaths, cli_kv_overrides: Vec<(String, TomlValue)>, harness_overrides: ConfigOverrides, active_profile: Option, + cloud_requirements: CloudRequirementsLoader, initial_prompt: Option, initial_images: Vec, session_selection: SessionSelection, @@ -1808,6 +2065,10 @@ impl App { let enhanced_keys_supported = tui.enhanced_keys_supported(); let wait_for_initial_session_configured = Self::should_wait_for_initial_session(&session_selection); + let mut existing_primary_thread: Option<( + Arc, + SessionConfiguredEvent, + )> = None; let mut chat_widget = match session_selection { SessionSelection::StartFresh | SessionSelection::Exit => { let startup_tooltip_override = @@ -1833,6 +2094,11 @@ impl App { startup_tooltip_override, status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), session_telemetry: session_telemetry.clone(), + in_process_context: InProcessAgentContext { + arg0_paths: arg0_paths.clone(), + cli_kv_overrides: cli_kv_overrides.clone(), + cloud_requirements: cloud_requirements.clone(), + }, }; ChatWidget::new(init, thread_manager.clone()) } @@ -1869,7 +2135,14 @@ impl App { startup_tooltip_override: None, status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), session_telemetry: session_telemetry.clone(), + in_process_context: InProcessAgentContext { + arg0_paths: arg0_paths.clone(), + cli_kv_overrides: cli_kv_overrides.clone(), + cloud_requirements: cloud_requirements.clone(), + }, }; + existing_primary_thread = + Some((resumed.thread.clone(), resumed.session_configured.clone())); ChatWidget::new_from_existing(init, resumed.thread, resumed.session_configured) } SessionSelection::Fork(target_session) => { @@ -1907,7 +2180,14 @@ impl App { startup_tooltip_override: None, status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), session_telemetry: session_telemetry.clone(), + in_process_context: InProcessAgentContext { + arg0_paths: arg0_paths.clone(), + cli_kv_overrides: cli_kv_overrides.clone(), + cloud_requirements: cloud_requirements.clone(), + }, }; + existing_primary_thread = + Some((forked.thread.clone(), forked.session_configured.clone())); ChatWidget::new_from_existing(init, forked.thread, forked.session_configured) } }; @@ -1927,6 +2207,8 @@ impl App { auth_manager: auth_manager.clone(), config, active_profile, + arg0_paths, + cloud_requirements, cli_kv_overrides, harness_overrides, runtime_approval_policy_override: None, @@ -1937,6 +2219,7 @@ impl App { overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, + pending_replay_rollbacks_to_ignore: 0, commit_anim_running: Arc::new(AtomicBool::new(false)), status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), backtrack: BacktrackState::default(), @@ -1949,6 +2232,8 @@ impl App { windows_sandbox: WindowsSandboxState::default(), thread_event_channels: HashMap::new(), thread_event_listener_tasks: HashMap::new(), + active_session_events_via_app_server: true, + primary_app_server_op_tx: None, agent_navigation: AgentNavigationState::default(), active_thread_id: None, active_thread_rx: None, @@ -1956,6 +2241,11 @@ impl App { primary_session_configured: None, pending_primary_events: VecDeque::new(), }; + app.primary_app_server_op_tx = app.chat_widget.thread_scoped_op_sender(); + if let Some((thread, session_configured)) = existing_primary_thread.take() { + app.attach_existing_primary_thread(thread, session_configured) + .await; + } // On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows. #[cfg(target_os = "windows")] @@ -2198,6 +2488,8 @@ impl App { { Ok(resumed) => { self.shutdown_current_thread().await; + let resumed_thread = resumed.thread.clone(); + let resumed_session_configured = resumed.session_configured.clone(); self.config = resume_config; tui.set_notification_method(self.config.tui_notification_method); self.file_search.update_search_dir(self.config.cwd.clone()); @@ -2211,6 +2503,11 @@ impl App { resumed.session_configured, ); self.reset_thread_event_state(); + self.attach_existing_primary_thread( + resumed_thread, + resumed_session_configured, + ) + .await; if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; @@ -2266,6 +2563,8 @@ impl App { { Ok(forked) => { self.shutdown_current_thread().await; + let forked_thread = forked.thread.clone(); + let forked_session_configured = forked.session_configured.clone(); let init = self.chatwidget_init_for_forked_or_resumed_thread( tui, self.config.clone(), @@ -2276,6 +2575,11 @@ impl App { forked.session_configured, ); self.reset_thread_event_state(); + self.attach_existing_primary_thread( + forked_thread, + forked_session_configured, + ) + .await; if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; @@ -2312,33 +2616,22 @@ impl App { tui.frame_requester().schedule_frame(); } AppEvent::InsertHistoryCell(cell) => { + self.insert_history_cell(tui, cell.into()); + } + AppEvent::InsertThreadHistoryCell { thread_id, cell } => { let cell: Arc = cell.into(); - if let Some(Overlay::Transcript(t)) = &mut self.overlay { - t.insert_cell(cell.clone()); - tui.frame_requester().schedule_frame(); + if let Some(channel) = self.thread_event_channels.get(&thread_id) { + let mut store = channel.store.lock().await; + store.push_thread_history_cell(cell.clone()); } - self.transcript_cells.push(cell.clone()); - let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width); - if !display.is_empty() { - // Only insert a separating blank line for new cells that are not - // part of an ongoing stream. Streaming continuations should not - // accrue extra blank lines between chunks. - if !cell.is_stream_continuation() { - if self.has_emitted_history_lines { - display.insert(0, Line::from("")); - } else { - self.has_emitted_history_lines = true; - } - } - if self.overlay.is_some() { - self.deferred_history_lines.extend(display); - } else { - tui.insert_history_lines(display); - } + if self.active_thread_id == Some(thread_id) { + self.insert_history_cell(tui, cell); } } AppEvent::ApplyThreadRollback { num_turns } => { - if self.apply_non_pending_thread_rollback(num_turns) { + if self.pending_replay_rollbacks_to_ignore > 0 { + self.pending_replay_rollbacks_to_ignore -= 1; + } else if self.apply_non_pending_thread_rollback(num_turns) { tui.frame_requester().schedule_frame(); } } @@ -2371,7 +2664,7 @@ impl App { self.handle_routed_thread_event(thread_id, event).await?; } AppEvent::Exit(mode) => { - return Ok(self.handle_exit_mode(mode)); + return Ok(self.handle_exit_mode(mode).await); } AppEvent::FatalExitRequest(message) => { return Ok(AppRunControl::Exit(ExitReason::Fatal(message))); @@ -3410,14 +3703,14 @@ impl App { Ok(AppRunControl::Continue) } - fn handle_exit_mode(&mut self, mode: ExitMode) -> AppRunControl { + async fn handle_exit_mode(&mut self, mode: ExitMode) -> AppRunControl { match mode { ExitMode::ShutdownFirst => { // Mark the thread we are explicitly shutting down for exit so // its shutdown completion does not trigger agent failover. self.pending_shutdown_exit_thread_id = self.active_thread_id.or(self.chat_widget.thread_id()); - if self.chat_widget.submit_op(Op::Shutdown) { + if self.submit_shutdown_for_exit().await { AppRunControl::Continue } else { self.pending_shutdown_exit_thread_id = None; @@ -3431,6 +3724,38 @@ impl App { } } + async fn submit_shutdown_for_exit(&mut self) -> bool { + let Some(thread_id) = self.pending_shutdown_exit_thread_id else { + return false; + }; + + if self.active_session_events_via_app_server && self.primary_thread_id == Some(thread_id) { + crate::session_log::log_outbound_op(&Op::Shutdown); + match self.server.get_thread(thread_id).await { + Ok(thread) => match thread.submit(Op::Shutdown).await { + Ok(_) => { + self.note_active_thread_outbound_op(&Op::Shutdown).await; + true + } + Err(err) => { + tracing::error!( + "failed to submit shutdown to primary app-server thread {thread_id}: {err}" + ); + false + } + }, + Err(err) => { + tracing::error!( + "failed to find primary app-server thread {thread_id} for shutdown: {err}" + ); + false + } + } + } else { + self.chat_widget.submit_op(Op::Shutdown) + } + } + fn handle_codex_event_now(&mut self, event: Event) { let needs_refresh = matches!( event.msg, @@ -3457,6 +3782,20 @@ impl App { } fn handle_codex_event_replay(&mut self, event: Event) { + if let EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns }) = &event.msg { + if self.transcript_cells.is_empty() { + self.app_event_tx.send(AppEvent::ApplyThreadRollback { + num_turns: *num_turns, + }); + } else { + self.pending_replay_rollbacks_to_ignore = + self.pending_replay_rollbacks_to_ignore.saturating_add(1); + crate::app_backtrack::trim_transcript_cells_drop_last_n_user_turns( + &mut self.transcript_cells, + *num_turns, + ); + } + } self.chat_widget.handle_codex_event_replay(event); } @@ -3524,6 +3863,52 @@ impl App { } }; let config_snapshot = thread.config_snapshot().await; + let session_configured = SessionConfiguredEvent { + session_id: thread_id, + forked_from_id: None, + thread_name: None, + model: config_snapshot.model, + model_provider_id: config_snapshot.model_provider_id, + service_tier: config_snapshot.service_tier, + approval_policy: config_snapshot.approval_policy, + sandbox_policy: config_snapshot.sandbox_policy, + cwd: config_snapshot.cwd, + reasoning_effort: config_snapshot.reasoning_effort, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: thread.rollout_path(), + }; + self.register_live_thread( + thread, + session_configured, + false, + !self.active_session_events_via_app_server, + ) + .await; + Ok(()) + } + + /// Register a `CodexThread` with the multi-thread infrastructure: create + /// its event channel, add it to the agent picker, and optionally spawn a + /// `next_event()` listener task. + /// + /// `spawn_listener` should be `false` when the thread's events already + /// arrive through another path (e.g. the in-process app-server stream). + /// `primary` marks the thread as the main session and activates its channel. + async fn register_live_thread( + &mut self, + thread: Arc, + session_configured: SessionConfiguredEvent, + primary: bool, + spawn_listener: bool, + ) { + let thread_id = session_configured.session_id; + if self.thread_event_channels.contains_key(&thread_id) { + return; + } + let config_snapshot = thread.config_snapshot().await; self.upsert_agent_picker_thread( thread_id, config_snapshot.session_source.get_nickname(), @@ -3532,43 +3917,79 @@ impl App { ); let event = Event { id: String::new(), - msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id: thread_id, - forked_from_id: None, - thread_name: None, - model: config_snapshot.model, - model_provider_id: config_snapshot.model_provider_id, - service_tier: config_snapshot.service_tier, - approval_policy: config_snapshot.approval_policy, - sandbox_policy: config_snapshot.sandbox_policy, - cwd: config_snapshot.cwd, - reasoning_effort: config_snapshot.reasoning_effort, - history_log_id: 0, - history_entry_count: 0, - initial_messages: None, - network_proxy: None, - rollout_path: thread.rollout_path(), - }), + msg: EventMsg::SessionConfigured(session_configured.clone()), }; let channel = ThreadEventChannel::new_with_session_configured(THREAD_EVENT_CHANNEL_CAPACITY, event); - let app_event_tx = self.app_event_tx.clone(); self.thread_event_channels.insert(thread_id, channel); - let listener_handle = tokio::spawn(async move { - loop { - let event = match thread.next_event().await { - Ok(event) => event, - Err(err) => { - tracing::debug!("external thread {thread_id} listener stopped: {err}"); - break; - } - }; - app_event_tx.send(AppEvent::ThreadEvent { thread_id, event }); - } - }); - self.thread_event_listener_tasks - .insert(thread_id, listener_handle); - Ok(()) + if spawn_listener { + let app_event_tx = self.app_event_tx.clone(); + let listener_handle = tokio::spawn(async move { + loop { + let event = match thread.next_event().await { + Ok(event) => event, + Err(err) => { + tracing::debug!("external thread {thread_id} listener stopped: {err}"); + break; + } + }; + app_event_tx.send(AppEvent::ThreadEvent { thread_id, event }); + } + }); + self.thread_event_listener_tasks + .insert(thread_id, listener_handle); + } + if primary { + self.primary_thread_id = Some(thread_id); + self.primary_session_configured = Some(session_configured); + self.activate_thread_channel(thread_id).await; + } + } + + async fn register_replay_only_thread( + &mut self, + session_configured: SessionConfiguredEvent, + primary: bool, + ) { + let thread_id = session_configured.session_id; + if self.thread_event_channels.contains_key(&thread_id) { + return; + } + let event = Event { + id: String::new(), + msg: EventMsg::SessionConfigured(session_configured.clone()), + }; + self.upsert_agent_picker_thread(thread_id, None, None, false); + let channel = + ThreadEventChannel::new_with_session_configured(THREAD_EVENT_CHANNEL_CAPACITY, event); + self.thread_event_channels.insert(thread_id, channel); + if primary { + self.primary_thread_id = Some(thread_id); + self.primary_session_configured = Some(session_configured); + self.activate_thread_channel(thread_id).await; + } + } + + /// Adopt a `CodexThread` obtained from `ThreadManager` (resume or fork) as + /// the primary session. Unlike a fresh `spawn_agent` session the thread + /// already exists, so this method registers it with a `next_event()` + /// listener and flips `active_session_events_via_app_server` off so + /// subsequent `register_live_thread` calls know the primary thread's events + /// come from the direct listener, not the app-server stream. + async fn attach_existing_primary_thread( + &mut self, + thread: Arc, + session_configured: SessionConfiguredEvent, + ) { + self.active_session_events_via_app_server = false; + self.primary_app_server_op_tx = None; + let event = Event { + id: String::new(), + msg: EventMsg::SessionConfigured(session_configured.clone()), + }; + self.register_live_thread(thread, session_configured, true, true) + .await; + self.handle_codex_event_now(event); } fn reasoning_label(reasoning_effort: Option) -> &'static str { @@ -3871,6 +4292,9 @@ mod tests { use crate::app_backtrack::BacktrackSelection; use crate::app_backtrack::BacktrackState; use crate::app_backtrack::user_count; + use crate::chatwidget::ChatWidget; + use crate::chatwidget::ChatWidgetInit; + use crate::chatwidget::InProcessAgentContext; use crate::chatwidget::tests::make_chatwidget_manual_with_sender; use crate::chatwidget::tests::set_chatgpt_auth; use crate::file_search::FileSearchManager; @@ -3879,6 +4303,7 @@ mod tests { use crate::history_cell::UserHistoryCell; use crate::history_cell::new_session_info; use crate::multi_agents::AgentPickerThreadEntry; + use crate::tui::FrameRequester; use assert_matches::assert_matches; use codex_core::CodexAuth; use codex_core::config::ConfigBuilder; @@ -3910,6 +4335,7 @@ mod tests { use insta::assert_snapshot; use pretty_assertions::assert_eq; use ratatui::prelude::Line; + use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -4166,54 +4592,192 @@ mod tests { } #[tokio::test] - async fn enqueue_thread_event_does_not_block_when_channel_full() -> Result<()> { + async fn app_server_managed_thread_creation_does_not_spawn_direct_listener() -> Result<()> { let mut app = make_test_app().await; - let thread_id = ThreadId::new(); - app.thread_event_channels - .insert(thread_id, ThreadEventChannel::new(1)); - app.set_thread_active(thread_id, true).await; - - let event = Event { - id: String::new(), - msg: EventMsg::ShutdownComplete, - }; - - app.enqueue_thread_event(thread_id, event.clone()).await?; - time::timeout( - Duration::from_millis(50), - app.enqueue_thread_event(thread_id, event), - ) - .await - .expect("enqueue_thread_event 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()) + let created = app + .server + .start_thread(app.config.clone()) .await - .expect("timed out waiting for second event") - .expect("channel closed unexpectedly"); + .expect("start thread"); + app.active_session_events_via_app_server = true; + app.handle_thread_created(created.thread_id).await?; + + assert!( + app.thread_event_channels.contains_key(&created.thread_id), + "new thread should still get a replay channel" + ); + assert!( + !app.thread_event_listener_tasks + .contains_key(&created.thread_id), + "app-server-managed threads should not start a competing next_event listener" + ); Ok(()) } #[tokio::test] - async fn replay_thread_snapshot_restores_draft_and_queued_input() { + async fn routed_child_session_configured_bootstraps_replay_thread_in_fresh_session() + -> Result<()> { let mut app = make_test_app().await; - let thread_id = ThreadId::new(); - app.thread_event_channels.insert( - thread_id, - ThreadEventChannel::new_with_session_configured( - THREAD_EVENT_CHANNEL_CAPACITY, + let main_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000011").expect("valid thread"); + let child_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000022").expect("valid thread"); + + app.active_session_events_via_app_server = true; + 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(1)); + + app.handle_routed_thread_event( + child_thread_id, + Event { + id: "child-session".to_string(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: child_thread_id, + forked_from_id: Some(main_thread_id), + thread_name: Some("child".to_string()), + model: "gpt-5".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::OnRequest, + sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + cwd: PathBuf::from("/tmp/child"), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: Some(PathBuf::from("/tmp/child-rollout.jsonl")), + }), + }, + ) + .await?; + + assert!( + app.thread_event_channels.contains_key(&child_thread_id), + "child session configured should register a replay thread channel" + ); + assert!( + !app.thread_event_listener_tasks + .contains_key(&child_thread_id), + "fresh in-process child threads should not spawn a competing listener" + ); + + app.handle_routed_thread_event( + child_thread_id, + Event { + id: "child-approval".to_string(), + msg: EventMsg::ExecApprovalRequest( + codex_protocol::protocol::ExecApprovalRequestEvent { + call_id: "call-approval".to_string(), + approval_id: None, + turn_id: "turn-approval".to_string(), + command: vec!["echo".to_string(), "hi".to_string()], + cwd: PathBuf::from("/tmp/child"), + reason: Some("need approval".to_string()), + network_approval_context: None, + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + additional_permissions: None, + skill_metadata: None, + available_decisions: None, + parsed_cmd: Vec::new(), + }, + ), + }, + ) + .await?; + + assert_eq!(app.chat_widget.pending_thread_approvals().len(), 1); + Ok(()) + } + + #[tokio::test] + async fn routed_child_shutdown_marks_agent_picker_thread_closed() -> Result<()> { + let mut app = make_test_app().await; + let main_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000071").expect("valid thread"); + let child_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000072").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(1)); + app.thread_event_channels + .insert(child_thread_id, ThreadEventChannel::new(1)); + app.upsert_agent_picker_thread(main_thread_id, Some("main".to_string()), None, false); + app.upsert_agent_picker_thread(child_thread_id, Some("child".to_string()), None, false); + + app.handle_routed_thread_event( + child_thread_id, + Event { + id: "child-shutdown".to_string(), + msg: EventMsg::ShutdownComplete, + }, + ) + .await?; + + assert_eq!( + app.agent_navigation + .get(&child_thread_id) + .map(|entry| entry.is_closed), + Some(true) + ); + 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(1)); + app.set_thread_active(thread_id, true).await; + + let event = Event { + id: String::new(), + msg: EventMsg::ShutdownComplete, + }; + + app.enqueue_thread_event(thread_id, event.clone()).await?; + time::timeout( + Duration::from_millis(50), + app.enqueue_thread_event(thread_id, event), + ) + .await + .expect("enqueue_thread_event 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 replay_thread_snapshot_restores_draft_and_queued_input() { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + app.thread_event_channels.insert( + thread_id, + ThreadEventChannel::new_with_session_configured( + THREAD_EVENT_CHANNEL_CAPACITY, Event { id: "session-configured".to_string(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { @@ -4344,13 +4908,13 @@ mod tests { app.replay_thread_snapshot( ThreadEventSnapshot { session_configured: None, - events: vec![Event { + replay_timeline: vec![ThreadReplayItem::Event(Box::new(Event { id: "turn-complete".to_string(), msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, }), - }], + }))], input_state: Some(input_state), }, true, @@ -4426,13 +4990,13 @@ mod tests { app.replay_thread_snapshot( ThreadEventSnapshot { session_configured: None, - events: vec![Event { + replay_timeline: vec![ThreadReplayItem::Event(Box::new(Event { id: "turn-complete".to_string(), msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, }), - }], + }))], input_state: Some(input_state), }, false, @@ -4506,7 +5070,7 @@ mod tests { app.replay_thread_snapshot( ThreadEventSnapshot { session_configured: None, - events: vec![], + replay_timeline: vec![], input_state: Some(input_state), }, true, @@ -4580,22 +5144,22 @@ mod tests { app.replay_thread_snapshot( ThreadEventSnapshot { session_configured: None, - events: vec![ - Event { + replay_timeline: vec![ + ThreadReplayItem::Event(Box::new(Event { id: "older-turn-complete".to_string(), msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-0".to_string(), last_agent_message: None, }), - }, - Event { + })), + ThreadReplayItem::Event(Box::new(Event { id: "latest-turn-started".to_string(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), model_context_window: None, collaboration_mode_kind: Default::default(), }), - }, + })), ], input_state: Some(input_state), }, @@ -4766,7 +5330,7 @@ mod tests { app.replay_thread_snapshot( ThreadEventSnapshot { session_configured: None, - events: vec![], + replay_timeline: vec![], input_state: Some(input_state), }, true, @@ -4866,7 +5430,7 @@ mod tests { app.replay_thread_snapshot( ThreadEventSnapshot { session_configured: None, - events: vec![], + replay_timeline: vec![], input_state: Some(input_state), }, true, @@ -4941,13 +5505,13 @@ mod tests { app.replay_thread_snapshot( ThreadEventSnapshot { session_configured: None, - events: vec![Event { + replay_timeline: vec![ThreadReplayItem::Event(Box::new(Event { id: "turn-aborted".to_string(), msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::ReviewEnded, }), - }], + }))], input_state: Some(input_state), }, true, @@ -4964,6 +5528,48 @@ mod tests { ); } + #[tokio::test] + async fn replay_thread_snapshot_keeps_local_history_cells_in_rollback_order() { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + + app.replay_thread_snapshot( + ThreadEventSnapshot { + session_configured: None, + replay_timeline: vec![ + ThreadReplayItem::HistoryCell(Arc::new(UserHistoryCell { + message: "first local prompt".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + })), + ThreadReplayItem::Event(Box::new(Event { + id: "rollback".to_string(), + msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }), + })), + ThreadReplayItem::HistoryCell(Arc::new(UserHistoryCell { + message: "second local prompt".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + })), + ], + input_state: None, + }, + false, + ); + + 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!["second local prompt".to_string()]); + } + #[tokio::test] async fn live_turn_started_refreshes_status_line_with_runtime_context_window() { let mut app = make_test_app().await; @@ -5144,95 +5750,568 @@ mod tests { "feature toggle should not patch the active session" ); - let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; - assert!(!config.contains("guardian_approval = true")); - assert!(!config.contains("approval_policy")); + let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + assert!(!config.contains("guardian_approval = true")); + assert!(!config.contains("approval_policy")); + 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) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + app.thread_event_channels + .insert(thread_id, ThreadEventChannel::new(1)); + + app.open_agent_picker().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(1)); + + let agent_channel = ThreadEventChannel::new(1); + { + let mut store = agent_channel.store.lock().await; + store.push_event(Event { + id: "ev-1".to_string(), + msg: EventMsg::ExecApprovalRequest( + codex_protocol::protocol::ExecApprovalRequestEvent { + call_id: "call-1".to_string(), + approval_id: None, + turn_id: "turn-1".to_string(), + command: vec!["echo".to_string(), "hi".to_string()], + cwd: PathBuf::from("/tmp"), + reason: None, + network_approval_context: None, + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + additional_permissions: None, + skill_metadata: None, + available_decisions: None, + parsed_cmd: Vec::new(), + }, + ), + }); + } + 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()), + 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(1)); + app.thread_event_channels.insert( + agent_thread_id, + ThreadEventChannel::new_with_session_configured( + 1, + Event { + id: String::new(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: agent_thread_id, + forked_from_id: None, + thread_name: None, + model: "gpt-5".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::OnRequest, + sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + cwd: PathBuf::from("/tmp/agent"), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: Some(PathBuf::from("/tmp/agent-rollout.jsonl")), + }), + }, + ), + ); + app.agent_navigation.upsert( + agent_thread_id, + Some("Robie".to_string()), + Some("explorer".to_string()), + false, + ); + + app.enqueue_thread_event( + agent_thread_id, + Event { + id: "ev-approval".to_string(), + msg: EventMsg::ExecApprovalRequest( + codex_protocol::protocol::ExecApprovalRequestEvent { + call_id: "call-approval".to_string(), + approval_id: None, + turn_id: "turn-approval".to_string(), + command: vec!["echo".to_string(), "hi".to_string()], + cwd: PathBuf::from("/tmp/agent"), + reason: Some("need approval".to_string()), + network_approval_context: None, + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + additional_permissions: None, + skill_metadata: None, + available_decisions: None, + parsed_cmd: Vec::new(), + }, + ), + }, + ) + .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 inactive_thread_patch_approval_bubbles_into_active_view() -> Result<()> { + let mut app = make_test_app().await; + let main_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000031").expect("valid thread"); + let agent_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000032").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(1)); + app.thread_event_channels.insert( + agent_thread_id, + ThreadEventChannel::new_with_session_configured( + 1, + Event { + id: String::new(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: agent_thread_id, + forked_from_id: None, + thread_name: None, + model: "gpt-5".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::OnRequest, + sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + cwd: PathBuf::from("/tmp/agent"), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: Some(PathBuf::from("/tmp/agent-rollout.jsonl")), + }), + }, + ), + ); + app.agent_navigation.upsert( + agent_thread_id, + Some("Patcher".to_string()), + Some("worker".to_string()), + false, + ); + + let mut changes = HashMap::new(); + changes.insert( + PathBuf::from("agent.txt"), + codex_protocol::protocol::FileChange::Add { + content: "created".to_string(), + }, + ); + app.enqueue_thread_event( + agent_thread_id, + Event { + id: "ev-patch-approval".to_string(), + msg: EventMsg::ApplyPatchApprovalRequest( + codex_protocol::protocol::ApplyPatchApprovalRequestEvent { + call_id: "call-patch-approval".to_string(), + turn_id: "turn-patch-approval".to_string(), + changes, + reason: Some("need patch approval".to_string()), + grant_root: None, + }, + ), + }, + ) + .await?; + + assert_eq!(app.chat_widget.has_active_view(), true); + assert_eq!( + app.chat_widget.pending_thread_approvals(), + &["Patcher [worker]".to_string()] + ); + + Ok(()) + } + + #[tokio::test] + async fn inactive_thread_permissions_request_bubbles_into_active_view() -> Result<()> { + let mut app = make_test_app().await; + let main_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000041").expect("valid thread"); + let agent_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000042").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(1)); + app.thread_event_channels.insert( + agent_thread_id, + ThreadEventChannel::new_with_session_configured( + 1, + Event { + id: String::new(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: agent_thread_id, + forked_from_id: None, + thread_name: None, + model: "gpt-5".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::OnRequest, + sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + cwd: PathBuf::from("/tmp/agent"), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: Some(PathBuf::from("/tmp/agent-rollout.jsonl")), + }), + }, + ), + ); + app.agent_navigation.upsert( + agent_thread_id, + Some("Perms".to_string()), + Some("worker".to_string()), + false, + ); + + app.enqueue_thread_event( + agent_thread_id, + Event { + id: "ev-permissions".to_string(), + msg: EventMsg::RequestPermissions( + codex_protocol::request_permissions::RequestPermissionsEvent { + call_id: "call-permissions".to_string(), + turn_id: "turn-permissions".to_string(), + reason: Some("need write access".to_string()), + permissions: codex_protocol::models::PermissionProfile::default(), + }, + ), + }, + ) + .await?; + + assert_eq!(app.chat_widget.has_active_view(), true); + assert_eq!( + app.chat_widget.pending_thread_approvals(), + &["Perms [worker]".to_string()] + ); + Ok(()) } #[tokio::test] - async fn open_agent_picker_allows_existing_agent_threads_when_feature_is_disabled() -> Result<()> - { + async fn inactive_thread_request_user_input_bubbles_into_active_view() -> Result<()> { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; - let thread_id = ThreadId::new(); + let main_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000051").expect("valid thread"); + let agent_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000052").expect("valid thread"); + + app.primary_thread_id = Some(main_thread_id); + app.active_thread_id = Some(main_thread_id); app.thread_event_channels - .insert(thread_id, ThreadEventChannel::new(1)); + .insert(main_thread_id, ThreadEventChannel::new(1)); + app.thread_event_channels.insert( + agent_thread_id, + ThreadEventChannel::new_with_session_configured( + 1, + Event { + id: String::new(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: agent_thread_id, + forked_from_id: None, + thread_name: None, + model: "gpt-5".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::OnRequest, + sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + cwd: PathBuf::from("/tmp/agent"), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: Some(PathBuf::from("/tmp/agent-rollout.jsonl")), + }), + }, + ), + ); + app.agent_navigation.upsert( + agent_thread_id, + Some("Input".to_string()), + Some("worker".to_string()), + false, + ); - app.open_agent_picker().await; + app.enqueue_thread_event( + agent_thread_id, + Event { + id: "ev-user-input".to_string(), + msg: EventMsg::RequestUserInput( + codex_protocol::request_user_input::RequestUserInputEvent { + call_id: "call-user-input".to_string(), + turn_id: "turn-user-input".to_string(), + questions: vec![ + codex_protocol::request_user_input::RequestUserInputQuestion { + id: "q1".to_string(), + header: "Question".to_string(), + question: "Pick one?".to_string(), + is_other: false, + is_secret: false, + options: Some(vec![ + codex_protocol::request_user_input::RequestUserInputQuestionOption { + label: "One".to_string(), + description: "First option".to_string(), + }, + ]), + }, + ], + }, + ), + }, + ) + .await?; + + assert_eq!(app.chat_widget.has_active_view(), true); + assert!(app.chat_widget.pending_thread_approvals().is_empty()); 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(()) + while let Ok(event) = app_event_rx.try_recv() { + if let AppEvent::SubmitThreadOp { + thread_id, + op: Op::UserInputAnswer { id, .. }, + } = event + { + assert_eq!(thread_id, agent_thread_id); + assert_eq!(id, "turn-user-input"); + return Ok(()); + } + } + + panic!("expected user input answer to submit to the source thread"); } #[tokio::test] - async fn refresh_pending_thread_approvals_only_lists_inactive_threads() { + async fn inactive_thread_turn_complete_dismisses_visible_user_input_prompt() -> Result<()> { let mut app = make_test_app().await; let main_thread_id = - ThreadId::from_string("00000000-0000-0000-0000-000000000001").expect("valid thread"); + ThreadId::from_string("00000000-0000-0000-0000-000000000081").expect("valid thread"); let agent_thread_id = - ThreadId::from_string("00000000-0000-0000-0000-000000000002").expect("valid thread"); + ThreadId::from_string("00000000-0000-0000-0000-000000000082").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(1)); - - let agent_channel = ThreadEventChannel::new(1); - { - let mut store = agent_channel.store.lock().await; - store.push_event(Event { - id: "ev-1".to_string(), - msg: EventMsg::ExecApprovalRequest( - codex_protocol::protocol::ExecApprovalRequestEvent { - call_id: "call-1".to_string(), - approval_id: None, - turn_id: "turn-1".to_string(), - command: vec!["echo".to_string(), "hi".to_string()], - cwd: PathBuf::from("/tmp"), - reason: None, - network_approval_context: None, - proposed_execpolicy_amendment: None, - proposed_network_policy_amendments: None, - additional_permissions: None, - skill_metadata: None, - available_decisions: None, - parsed_cmd: Vec::new(), - }, - ), - }); - } - app.thread_event_channels - .insert(agent_thread_id, agent_channel); + app.thread_event_channels.insert( + agent_thread_id, + ThreadEventChannel::new_with_session_configured( + 1, + Event { + id: String::new(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: agent_thread_id, + forked_from_id: None, + thread_name: None, + model: "gpt-5".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::OnRequest, + sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + cwd: PathBuf::from("/tmp/agent"), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: Some(PathBuf::from("/tmp/agent-rollout.jsonl")), + }), + }, + ), + ); app.agent_navigation.upsert( agent_thread_id, - Some("Robie".to_string()), - Some("explorer".to_string()), + Some("Input".to_string()), + Some("worker".to_string()), false, ); - app.refresh_pending_thread_approvals().await; + app.enqueue_thread_event( + agent_thread_id, + Event { + id: "ev-user-input".to_string(), + msg: EventMsg::RequestUserInput( + codex_protocol::request_user_input::RequestUserInputEvent { + call_id: "call-user-input".to_string(), + turn_id: "turn-user-input".to_string(), + questions: vec![ + codex_protocol::request_user_input::RequestUserInputQuestion { + id: "q1".to_string(), + header: "Question".to_string(), + question: "Pick one?".to_string(), + is_other: false, + is_secret: false, + options: Some(vec![ + codex_protocol::request_user_input::RequestUserInputQuestionOption { + label: "One".to_string(), + description: "First option".to_string(), + }, + ]), + }, + ], + }, + ), + }, + ) + .await?; + + assert!(app.chat_widget.has_active_view()); + + app.enqueue_thread_event( + agent_thread_id, + Event { + id: "ev-turn-complete".to_string(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-user-input".to_string(), + last_agent_message: None, + }), + }, + ) + .await?; + + assert!(!app.chat_widget.has_active_view()); + Ok(()) + } + + #[tokio::test] + async fn submit_op_to_thread_routes_interactive_ops_via_app_server() { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + let (thread_op_tx, mut thread_op_rx) = tokio::sync::mpsc::unbounded_channel(); + let thread_id = ThreadId::new(); + app.active_session_events_via_app_server = true; + app.primary_app_server_op_tx = Some(thread_op_tx); + app.thread_event_channels + .insert(thread_id, ThreadEventChannel::new(8)); + app.enqueue_thread_event( + thread_id, + Event { + id: "turn-1".to_string(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }, + ) + .await + .expect("turn start should enqueue"); + + app.submit_op_to_thread( + thread_id, + Op::UserInputAnswer { + id: "turn-user-input".to_string(), + response: codex_protocol::request_user_input::RequestUserInputResponse { + answers: HashMap::new(), + }, + }, + ) + .await; + assert_eq!( - app.chat_widget.pending_thread_approvals(), - &["Robie [explorer]".to_string()] + thread_op_rx.try_recv(), + Ok(ThreadScopedOp { + thread_id, + op: Op::UserInputAnswer { + id: "turn-user-input".to_string(), + response: codex_protocol::request_user_input::RequestUserInputResponse { + answers: HashMap::new(), + }, + }, + interrupt_turn_id: None, + }) ); - app.active_thread_id = Some(agent_thread_id); - app.refresh_pending_thread_approvals().await; - assert!(app.chat_widget.pending_thread_approvals().is_empty()); + app.submit_op_to_thread(thread_id, Op::Interrupt).await; + + assert_eq!( + thread_op_rx.try_recv(), + Ok(ThreadScopedOp { + thread_id, + op: Op::Interrupt, + interrupt_turn_id: Some("turn-1".to_string()), + }) + ); } #[tokio::test] - async fn inactive_thread_approval_bubbles_into_active_view() -> Result<()> { + async fn inactive_thread_mcp_elicitation_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"); + ThreadId::from_string("00000000-0000-0000-0000-000000000061").expect("valid thread"); let agent_thread_id = - ThreadId::from_string("00000000-0000-0000-0000-000000000022").expect("valid thread"); + ThreadId::from_string("00000000-0000-0000-0000-000000000062").expect("valid thread"); app.primary_thread_id = Some(main_thread_id); app.active_thread_id = Some(main_thread_id); @@ -5266,30 +6345,26 @@ mod tests { ); app.agent_navigation.upsert( agent_thread_id, - Some("Robie".to_string()), - Some("explorer".to_string()), + Some("MCP".to_string()), + Some("worker".to_string()), false, ); app.enqueue_thread_event( agent_thread_id, Event { - id: "ev-approval".to_string(), - msg: EventMsg::ExecApprovalRequest( - codex_protocol::protocol::ExecApprovalRequestEvent { - call_id: "call-approval".to_string(), - approval_id: None, - turn_id: "turn-approval".to_string(), - command: vec!["echo".to_string(), "hi".to_string()], - cwd: PathBuf::from("/tmp/agent"), - reason: Some("need approval".to_string()), - network_approval_context: None, - proposed_execpolicy_amendment: None, - proposed_network_policy_amendments: None, - additional_permissions: None, - skill_metadata: None, - available_decisions: None, - parsed_cmd: Vec::new(), + id: "ev-elicitation".to_string(), + msg: EventMsg::ElicitationRequest( + codex_protocol::approvals::ElicitationRequestEvent { + turn_id: Some("turn-elicitation".to_string()), + server_name: "server-1".to_string(), + id: codex_protocol::mcp::RequestId::Integer(7), + request: codex_protocol::approvals::ElicitationRequest::Url { + meta: None, + message: "Open this URL?".to_string(), + url: "https://example.com".to_string(), + elicitation_id: "elicitation-1".to_string(), + }, }, ), }, @@ -5299,7 +6374,7 @@ mod tests { assert_eq!(app.chat_widget.has_active_view(), true); assert_eq!( app.chat_widget.pending_thread_approvals(), - &["Robie [explorer]".to_string()] + &["MCP [worker]".to_string()] ); Ok(()) @@ -5592,6 +6667,8 @@ mod tests { auth_manager, config, active_profile: None, + arg0_paths: Arg0DispatchPaths::default(), + cloud_requirements: CloudRequirementsLoader::default(), cli_kv_overrides: Vec::new(), harness_overrides: ConfigOverrides::default(), runtime_approval_policy_override: None, @@ -5601,6 +6678,7 @@ mod tests { overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, + pending_replay_rollbacks_to_ignore: 0, enhanced_keys_supported: false, commit_anim_running: Arc::new(AtomicBool::new(false)), status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), @@ -5614,6 +6692,8 @@ mod tests { windows_sandbox: WindowsSandboxState::default(), thread_event_channels: HashMap::new(), thread_event_listener_tasks: HashMap::new(), + active_session_events_via_app_server: false, + primary_app_server_op_tx: None, agent_navigation: AgentNavigationState::default(), active_thread_id: None, active_thread_rx: None, @@ -5652,6 +6732,8 @@ mod tests { auth_manager, config, active_profile: None, + arg0_paths: Arg0DispatchPaths::default(), + cloud_requirements: CloudRequirementsLoader::default(), cli_kv_overrides: Vec::new(), harness_overrides: ConfigOverrides::default(), runtime_approval_policy_override: None, @@ -5661,6 +6743,7 @@ mod tests { overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, + pending_replay_rollbacks_to_ignore: 0, enhanced_keys_supported: false, commit_anim_running: Arc::new(AtomicBool::new(false)), status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), @@ -5674,6 +6757,8 @@ mod tests { windows_sandbox: WindowsSandboxState::default(), thread_event_channels: HashMap::new(), thread_event_listener_tasks: HashMap::new(), + active_session_events_via_app_server: false, + primary_app_server_op_tx: None, agent_navigation: AgentNavigationState::default(), active_thread_id: None, active_thread_rx: None, @@ -6484,7 +7569,6 @@ mod tests { }), }); - let mut saw_rollback = false; while let Ok(event) = app_event_rx.try_recv() { match event { AppEvent::InsertHistoryCell(cell) => { @@ -6492,7 +7576,6 @@ mod tests { app.transcript_cells.push(cell); } AppEvent::ApplyThreadRollback { num_turns } => { - saw_rollback = true; crate::app_backtrack::trim_transcript_cells_drop_last_n_user_turns( &mut app.transcript_cells, num_turns, @@ -6502,7 +7585,6 @@ mod tests { } } - assert!(saw_rollback); let user_messages: Vec = app .transcript_cells .iter() @@ -6687,13 +7769,52 @@ mod tests { } } + #[tokio::test] + async fn new_session_shutdown_closes_previous_conversation_in_app_server_mode() { + let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; + app.active_session_events_via_app_server = true; + + let new_thread = app + .server + .start_thread(app.config.clone()) + .await + .expect("start thread"); + let thread = Arc::clone(&new_thread.thread); + let thread_id = new_thread.thread_id; + + app.chat_widget.handle_codex_event(Event { + id: String::new(), + msg: EventMsg::SessionConfigured(new_thread.session_configured), + }); + while op_rx.try_recv().is_ok() {} + + app.shutdown_current_thread().await; + + assert!( + app.server.get_thread(thread_id).await.is_err(), + "expected switched thread to be removed from the shared manager" + ); + assert_eq!(op_rx.try_recv(), Err(TryRecvError::Empty)); + + time::timeout(std::time::Duration::from_secs(5), async move { + loop { + let event = thread.next_event().await.expect("thread event"); + if matches!(event.msg, EventMsg::ShutdownComplete) { + break; + } + } + }) + .await + .expect("expected switched thread to shut down"); + } + #[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 control = app.handle_exit_mode(ExitMode::ShutdownFirst); + let control = app.handle_exit_mode(ExitMode::ShutdownFirst).await; assert_eq!(app.pending_shutdown_exit_thread_id, None); assert!(matches!( @@ -6708,13 +7829,77 @@ mod tests { let thread_id = ThreadId::new(); app.active_thread_id = Some(thread_id); - let control = app.handle_exit_mode(ExitMode::ShutdownFirst); + let control = app.handle_exit_mode(ExitMode::ShutdownFirst).await; assert_eq!(app.pending_shutdown_exit_thread_id, Some(thread_id)); assert!(matches!(control, AppRunControl::Continue)); assert_eq!(op_rx.try_recv(), Ok(Op::Shutdown)); } + #[tokio::test] + async fn shutdown_first_exit_submits_primary_app_server_shutdown_to_core_thread() { + let mut app = make_test_app().await; + let (thread_scoped_op_tx, mut thread_scoped_op_rx) = unbounded_channel(); + let (codex_op_tx, mut codex_op_rx) = unbounded_channel(); + let new_thread = app + .server + .start_thread(app.config.clone()) + .await + .expect("start thread"); + let thread = Arc::clone(&new_thread.thread); + let thread_id = new_thread.thread_id; + let resolved_model = + codex_core::test_support::get_model_offline(app.config.model.as_deref()); + + app.chat_widget = ChatWidget::new_with_op_sender( + ChatWidgetInit { + config: app.config.clone(), + frame_requester: FrameRequester::test_dummy(), + app_event_tx: app.app_event_tx.clone(), + initial_user_message: None, + enhanced_keys_supported: false, + auth_manager: app.auth_manager.clone(), + models_manager: app.server.get_models_manager(), + feedback: codex_feedback::CodexFeedback::new(), + is_first_run: false, + feedback_audience: FeedbackAudience::External, + model: Some(resolved_model.clone()), + startup_tooltip_override: None, + status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), + session_telemetry: test_session_telemetry(&app.config, resolved_model.as_str()), + in_process_context: InProcessAgentContext { + arg0_paths: Arg0DispatchPaths::default(), + cli_kv_overrides: Vec::new(), + cloud_requirements: CloudRequirementsLoader::default(), + }, + }, + codex_op_tx, + Some(thread_scoped_op_tx), + Some(thread_id), + ); + app.active_session_events_via_app_server = true; + app.active_thread_id = Some(thread_id); + app.primary_thread_id = Some(thread_id); + + let control = app.handle_exit_mode(ExitMode::ShutdownFirst).await; + + assert_eq!(app.pending_shutdown_exit_thread_id, Some(thread_id)); + assert!(matches!(control, AppRunControl::Continue)); + assert_eq!(thread_scoped_op_rx.try_recv(), Err(TryRecvError::Empty)); + assert_eq!(codex_op_rx.try_recv(), Err(TryRecvError::Empty)); + + time::timeout(std::time::Duration::from_secs(5), async move { + loop { + let event = thread.next_event().await.expect("thread event"); + if matches!(event.msg, EventMsg::ShutdownComplete) { + break; + } + } + }) + .await + .expect("expected primary thread to shut down through core"); + } + #[tokio::test] async fn clear_only_ui_reset_preserves_chat_session_state() { let mut app = make_test_app().await; diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 02efb2ec1bf..9ff2b197d9f 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -20,19 +20,25 @@ impl ElicitationRequestKey { } #[derive(Debug, Default)] -// Tracks which interactive prompts are still unresolved in the thread-event buffer. -// -// Thread snapshots are replayed when switching threads/agents. Most events should replay -// verbatim, but interactive prompts (approvals, request_user_input, MCP elicitations) must -// only replay if they are still pending. This state is updated from: -// - inbound events (`note_event`) -// - outbound ops that resolve a prompt (`note_outbound_op`) -// - buffer eviction (`note_evicted_event`) -// -// We keep both fast lookup sets (for snapshot filtering by call_id/request key) and -// turn-indexed queues/vectors so `TurnComplete`/`TurnAborted` can clear stale prompts tied -// to a turn. `request_user_input` removal is FIFO because the overlay answers queued prompts -// in FIFO order for a shared `turn_id`. +/// Tracks which interactive prompts are still unresolved in the thread-event buffer. +/// +/// Thread snapshots are replayed when switching threads/agents. Most events +/// replay verbatim, but interactive prompts (approvals, user-input requests, +/// MCP elicitations) must only replay if they are still pending — otherwise +/// the user would see already-answered approval dialogs reappear on every +/// thread switch. +/// +/// State is updated from three sources: +/// - inbound events ([`note_event`](Self::note_event)) — registers new prompts +/// - outbound ops ([`note_outbound_op`](Self::note_outbound_op)) — marks prompts resolved +/// - buffer eviction ([`note_evicted_event`](Self::note_evicted_event)) — cleans up when +/// the replay timeline exceeds capacity +/// +/// We maintain both fast-lookup `HashSet`s (keyed by `call_id` or +/// `ElicitationRequestKey` for snapshot filtering) and turn-indexed `HashMap`s +/// so that `TurnComplete`/`TurnAborted` can bulk-clear all prompts tied to a +/// turn. `request_user_input` removal is FIFO because the overlay answers +/// queued prompts in arrival order for a shared `turn_id`. pub(super) struct PendingInteractiveReplayState { exec_approval_call_ids: HashSet, exec_approval_call_ids_by_turn_id: HashMap>, @@ -170,16 +176,16 @@ impl PendingInteractiveReplayState { ev.id.clone(), )); } - EventMsg::RequestUserInput(ev) => { - self.request_user_input_call_ids.insert(ev.call_id.clone()); - self.request_user_input_call_ids_by_turn_id + EventMsg::RequestPermissions(ev) => { + self.request_permissions_call_ids.insert(ev.call_id.clone()); + self.request_permissions_call_ids_by_turn_id .entry(ev.turn_id.clone()) .or_default() .push(ev.call_id.clone()); } - EventMsg::RequestPermissions(ev) => { - self.request_permissions_call_ids.insert(ev.call_id.clone()); - self.request_permissions_call_ids_by_turn_id + EventMsg::RequestUserInput(ev) => { + self.request_user_input_call_ids.insert(ev.call_id.clone()); + self.request_user_input_call_ids_by_turn_id .entry(ev.turn_id.clone()) .or_default() .push(ev.call_id.clone()); @@ -231,6 +237,14 @@ impl PendingInteractiveReplayState { ev.id.clone(), )); } + EventMsg::RequestPermissions(ev) => { + self.request_permissions_call_ids.remove(&ev.call_id); + Self::remove_call_id_from_turn_map_entry( + &mut self.request_permissions_call_ids_by_turn_id, + &ev.turn_id, + &ev.call_id, + ); + } EventMsg::RequestUserInput(ev) => { self.request_user_input_call_ids.remove(&ev.call_id); let mut remove_turn_entry = false; @@ -248,23 +262,6 @@ impl PendingInteractiveReplayState { .remove(&ev.turn_id); } } - EventMsg::RequestPermissions(ev) => { - self.request_permissions_call_ids.remove(&ev.call_id); - let mut remove_turn_entry = false; - if let Some(call_ids) = self - .request_permissions_call_ids_by_turn_id - .get_mut(&ev.turn_id) - { - call_ids.retain(|call_id| call_id != &ev.call_id); - if call_ids.is_empty() { - remove_turn_entry = true; - } - } - if remove_turn_entry { - self.request_permissions_call_ids_by_turn_id - .remove(&ev.turn_id); - } - } _ => {} } } @@ -284,12 +281,12 @@ impl PendingInteractiveReplayState { ev.id.clone(), )) } - EventMsg::RequestUserInput(ev) => { - self.request_user_input_call_ids.contains(&ev.call_id) - } EventMsg::RequestPermissions(ev) => { self.request_permissions_call_ids.contains(&ev.call_id) } + EventMsg::RequestUserInput(ev) => { + self.request_user_input_call_ids.contains(&ev.call_id) + } _ => true, } } @@ -376,6 +373,7 @@ impl PendingInteractiveReplayState { #[cfg(test)] mod tests { use super::super::ThreadEventStore; + use super::super::ThreadReplayItem; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; @@ -384,6 +382,31 @@ mod tests { use std::collections::HashMap; use std::path::PathBuf; + fn request_permissions_event(call_id: &str, turn_id: &str) -> Event { + Event { + id: format!("ev-{call_id}"), + msg: EventMsg::RequestPermissions( + codex_protocol::request_permissions::RequestPermissionsEvent { + call_id: call_id.to_string(), + turn_id: turn_id.to_string(), + reason: Some("Select a root".to_string()), + permissions: codex_protocol::models::PermissionProfile::default(), + }, + ), + } + } + + fn snapshot_events(snapshot: &super::super::ThreadEventSnapshot) -> Vec<&Event> { + snapshot + .replay_timeline + .iter() + .filter_map(|item| match item { + ThreadReplayItem::Event(event) => Some(event.as_ref()), + ThreadReplayItem::HistoryCell(_) => None, + }) + .collect() + } + #[test] fn thread_event_snapshot_keeps_pending_request_user_input() { let mut store = ThreadEventStore::new(8); @@ -401,13 +424,48 @@ mod tests { store.push_event(request); let snapshot = store.snapshot(); - assert_eq!(snapshot.events.len(), 1); + let events = snapshot_events(&snapshot); + assert_eq!(events.len(), 1); assert!(matches!( - snapshot.events.first().map(|event| &event.msg), + events.first().map(|event| &event.msg), Some(EventMsg::RequestUserInput(_)) )); } + #[test] + fn thread_event_snapshot_keeps_pending_request_permissions() { + let mut store = ThreadEventStore::new(8); + store.push_event(request_permissions_event("call-1", "turn-1")); + + let snapshot = store.snapshot(); + let events = snapshot_events(&snapshot); + assert_eq!(events.len(), 1); + assert!(matches!( + events.first().map(|event| &event.msg), + Some(EventMsg::RequestPermissions(_)) + )); + } + + #[test] + fn thread_event_snapshot_drops_resolved_request_permissions_after_response() { + let mut store = ThreadEventStore::new(8); + store.push_event(request_permissions_event("call-1", "turn-1")); + + store.note_outbound_op(&Op::RequestPermissionsResponse { + id: "call-1".to_string(), + response: codex_protocol::request_permissions::RequestPermissionsResponse { + permissions: codex_protocol::models::PermissionProfile::default(), + scope: codex_protocol::request_permissions::PermissionGrantScope::Turn, + }, + }); + + let snapshot = store.snapshot(); + assert!( + snapshot_events(&snapshot).is_empty(), + "resolved request_permissions prompt should not replay on thread switch" + ); + } + #[test] fn thread_event_snapshot_drops_resolved_request_user_input_after_user_answer() { let mut store = ThreadEventStore::new(8); @@ -431,7 +489,7 @@ mod tests { let snapshot = store.snapshot(); assert!( - snapshot.events.is_empty(), + snapshot_events(&snapshot).is_empty(), "resolved request_user_input prompt should not replay on thread switch" ); } @@ -468,7 +526,7 @@ mod tests { let snapshot = store.snapshot(); assert!( - snapshot.events.is_empty(), + snapshot_events(&snapshot).is_empty(), "resolved exec approval prompt should not replay on thread switch" ); } @@ -506,9 +564,10 @@ mod tests { }); let snapshot = store.snapshot(); - assert_eq!(snapshot.events.len(), 1); + let events = snapshot_events(&snapshot); + assert_eq!(events.len(), 1); assert!(matches!( - snapshot.events.first().map(|event| &event.msg), + events.first().map(|event| &event.msg), Some(EventMsg::RequestUserInput(ev)) if ev.call_id == "call-2" )); } @@ -545,9 +604,10 @@ mod tests { }); let snapshot = store.snapshot(); - assert_eq!(snapshot.events.len(), 1); + let events = snapshot_events(&snapshot); + assert_eq!(events.len(), 1); assert!(matches!( - snapshot.events.first().map(|event| &event.msg), + events.first().map(|event| &event.msg), Some(EventMsg::RequestUserInput(ev)) if ev.call_id == "call-2" )); } @@ -575,7 +635,7 @@ mod tests { let snapshot = store.snapshot(); assert!( - snapshot.events.is_empty(), + snapshot_events(&snapshot).is_empty(), "resolved patch approval prompt should not replay on thread switch" ); } @@ -617,6 +677,17 @@ mod tests { }); store.push_event(Event { id: "ev-3".to_string(), + msg: EventMsg::RequestPermissions( + codex_protocol::request_permissions::RequestPermissionsEvent { + call_id: "permissions-call-1".to_string(), + turn_id: "turn-1".to_string(), + reason: Some("Select a root".to_string()), + permissions: codex_protocol::models::PermissionProfile::default(), + }, + ), + }); + store.push_event(Event { + id: "ev-4".to_string(), msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Replaced, @@ -624,10 +695,13 @@ mod tests { }); let snapshot = store.snapshot(); - assert!(snapshot.events.iter().all(|event| { + let events = snapshot_events(&snapshot); + assert!(events.iter().all(|event| { !matches!( &event.msg, - EventMsg::ExecApprovalRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) + EventMsg::ExecApprovalRequest(_) + | EventMsg::ApplyPatchApprovalRequest(_) + | EventMsg::RequestPermissions(_) ) })); } @@ -663,7 +737,7 @@ mod tests { let snapshot = store.snapshot(); assert!( - snapshot.events.is_empty(), + snapshot_events(&snapshot).is_empty(), "resolved elicitation prompt should not replay on thread switch" ); } @@ -705,6 +779,24 @@ mod tests { assert_eq!(store.has_pending_thread_approvals(), false); } + #[test] + fn request_permissions_counts_as_pending_thread_approval() { + let mut store = ThreadEventStore::new(8); + store.push_event(request_permissions_event("call-1", "turn-1")); + + assert_eq!(store.has_pending_thread_approvals(), true); + + store.note_outbound_op(&Op::RequestPermissionsResponse { + id: "call-1".to_string(), + response: codex_protocol::request_permissions::RequestPermissionsResponse { + permissions: codex_protocol::models::PermissionProfile::default(), + scope: codex_protocol::request_permissions::PermissionGrantScope::Turn, + }, + }); + + assert_eq!(store.has_pending_thread_approvals(), false); + } + #[test] fn request_user_input_does_not_count_as_pending_thread_approval() { let mut store = ThreadEventStore::new(8); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 9f9a8d6de1e..c3770b3b5b4 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -86,6 +86,12 @@ pub(crate) enum AppEvent { event: Event, }, + /// Insert a local-only history cell for a specific thread. + InsertThreadHistoryCell { + thread_id: ThreadId, + cell: Box, + }, + /// Start a new session. NewSession, diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 2420fb3235f..9a6e765441f 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -42,7 +42,15 @@ use ratatui::text::Span; use ratatui::widgets::Paragraph; use ratatui::widgets::Wrap; -/// Request coming from the agent that needs user approval. +/// An interactive approval request from an agent thread awaiting user decision. +/// +/// Each variant carries a `thread_id` and optional `thread_label` so the UI +/// can display which thread originated the request and route the user's +/// response back to the correct thread via [`ThreadScopedOp`]. +/// +/// Approval requests arrive both from the active thread (shown inline) and +/// from inactive background threads (bubbled up via the pending-approval +/// queue in `App`). #[derive(Clone, Debug)] pub(crate) enum ApprovalRequest { Exec { @@ -97,6 +105,66 @@ impl ApprovalRequest { | ApprovalRequest::McpElicitation { thread_label, .. } => thread_label.as_deref(), } } + + fn same_identity(&self, other: &Self) -> bool { + match (self, other) { + ( + ApprovalRequest::Exec { + thread_id: left_thread_id, + id: left_id, + .. + }, + ApprovalRequest::Exec { + thread_id: right_thread_id, + id: right_id, + .. + }, + ) => left_thread_id == right_thread_id && left_id == right_id, + ( + ApprovalRequest::Permissions { + thread_id: left_thread_id, + call_id: left_call_id, + .. + }, + ApprovalRequest::Permissions { + thread_id: right_thread_id, + call_id: right_call_id, + .. + }, + ) => left_thread_id == right_thread_id && left_call_id == right_call_id, + ( + ApprovalRequest::ApplyPatch { + thread_id: left_thread_id, + id: left_id, + .. + }, + ApprovalRequest::ApplyPatch { + thread_id: right_thread_id, + id: right_id, + .. + }, + ) => left_thread_id == right_thread_id && left_id == right_id, + ( + ApprovalRequest::McpElicitation { + thread_id: left_thread_id, + server_name: left_server_name, + request_id: left_request_id, + .. + }, + ApprovalRequest::McpElicitation { + thread_id: right_thread_id, + server_name: right_server_name, + request_id: right_request_id, + .. + }, + ) => { + left_thread_id == right_thread_id + && left_server_name == right_server_name + && left_request_id == right_request_id + } + _ => false, + } + } } /// Modal overlay asking the user to approve or deny one or more requests. @@ -128,6 +196,14 @@ impl ApprovalOverlay { } pub fn enqueue_request(&mut self, req: ApprovalRequest) { + if self + .current_request + .as_ref() + .is_some_and(|current| current.same_identity(&req)) + || self.queue.iter().any(|queued| queued.same_identity(&req)) + { + return; + } self.queue.push(req); } @@ -385,6 +461,12 @@ impl ApprovalOverlay { code: KeyCode::Char('o'), .. } => { + if matches!( + self.current_request.as_ref(), + Some(ApprovalRequest::Permissions { .. }) + ) { + return false; + } if let Some(request) = self.current_request.as_ref() { if request.thread_label().is_some() { self.app_event_tx @@ -414,6 +496,53 @@ impl ApprovalOverlay { } impl BottomPaneView for ApprovalOverlay { + fn thread_id(&self) -> Option { + self.current_request + .as_ref() + .map(ApprovalRequest::thread_id) + .or_else(|| self.queue.first().map(ApprovalRequest::thread_id)) + } + + fn dismiss_on_turn_interrupt(&mut self, interrupted_thread_id: ThreadId) -> bool { + if self.preserve_on_turn_interrupt() { + return true; + } + + self.queue + .retain(|request| request.thread_id() != interrupted_thread_id); + + if self + .current_request + .as_ref() + .is_some_and(|request| request.thread_id() == interrupted_thread_id) + { + self.current_request = None; + self.current_complete = false; + self.options.clear(); + self.advance_queue(); + } + + !self.done + } + + fn dismiss_on_thread_finished(&mut self, finished_thread_id: ThreadId) -> bool { + self.queue + .retain(|request| request.thread_id() != finished_thread_id); + + if self + .current_request + .as_ref() + .is_some_and(|request| request.thread_id() == finished_thread_id) + { + self.current_request = None; + self.current_complete = false; + self.options.clear(); + self.advance_queue(); + } + + !self.done + } + fn handle_key_event(&mut self, key_event: KeyEvent) { if self.try_handle_shortcut(&key_event) { return; @@ -905,6 +1034,7 @@ mod tests { use codex_utils_absolute_path::AbsolutePathBuf; use insta::assert_snapshot; use pretty_assertions::assert_eq; + use tokio::sync::mpsc::error::TryRecvError; use tokio::sync::mpsc::unbounded_channel; fn absolute_path(path: &str) -> AbsolutePathBuf { @@ -999,6 +1129,62 @@ mod tests { assert!(saw_op, "expected approval decision to emit an op"); } + #[test] + fn duplicate_request_is_not_queued_twice() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let request = make_exec_request(); + let mut view = ApprovalOverlay::new(request.clone(), tx, Features::with_defaults()); + + view.enqueue_request(request); + view.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + + assert!( + view.is_complete(), + "expected duplicate approval request to be ignored instead of remaining queued" + ); + + let events: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect(); + let submit_count = events + .iter() + .filter(|event| matches!(event, AppEvent::SubmitThreadOp { .. })) + .count(); + let history_count = events + .iter() + .filter(|event| matches!(event, AppEvent::InsertHistoryCell(_))) + .count(); + assert_eq!(submit_count, 1); + assert_eq!(history_count, 1); + } + + #[test] + fn duplicate_permissions_request_is_not_queued_twice() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let request = make_permissions_request(); + let mut view = ApprovalOverlay::new(request.clone(), tx, Features::with_defaults()); + + view.enqueue_request(request); + view.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + + assert!( + view.is_complete(), + "expected duplicate permissions request to be ignored instead of remaining queued" + ); + + let events: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect(); + let submit_count = events + .iter() + .filter(|event| matches!(event, AppEvent::SubmitThreadOp { .. })) + .count(); + let history_count = events + .iter() + .filter(|event| matches!(event, AppEvent::InsertHistoryCell(_))) + .count(); + assert_eq!(submit_count, 1); + assert_eq!(history_count, 1); + } + #[test] fn o_opens_source_thread_for_cross_thread_approval() { let (tx, mut rx) = unbounded_channel::(); @@ -1028,6 +1214,34 @@ mod tests { ); } + #[test] + fn permissions_prompt_does_not_treat_o_as_cross_thread_shortcut() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let mut view = ApprovalOverlay::new( + ApprovalRequest::Permissions { + thread_id: ThreadId::new(), + thread_label: Some("Robie [explorer]".to_string()), + call_id: "permission-request".to_string(), + reason: Some("need permissions".to_string()), + permissions: PermissionProfile { + macos: Some(MacOsSeatbeltProfileExtensions { + macos_automation: MacOsAutomationPermission::All, + ..Default::default() + }), + ..Default::default() + }, + }, + tx, + Features::with_defaults(), + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)); + + assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty))); + assert_eq!(view.list.search_query_for_test(), ""); + } + #[test] fn cross_thread_footer_hint_mentions_o_shortcut() { let (tx, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index 35165db491e..468d180350e 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -1,7 +1,8 @@ use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::McpServerElicitationFormRequest; +use crate::bottom_pane::ThreadUserInputRequest; use crate::render::renderable::Renderable; -use codex_protocol::request_user_input::RequestUserInputEvent; +use codex_protocol::ThreadId; use crossterm::event::KeyEvent; use super::CancellationEvent; @@ -74,8 +75,8 @@ pub(crate) trait BottomPaneView: Renderable { /// consumed. fn try_consume_user_input_request( &mut self, - request: RequestUserInputEvent, - ) -> Option { + request: ThreadUserInputRequest, + ) -> Option { Some(request) } @@ -87,4 +88,30 @@ pub(crate) trait BottomPaneView: Renderable { ) -> Option { Some(request) } + + /// Whether this view should remain visible after a turn interrupt clears + /// turn-scoped approvals. + fn preserve_on_turn_interrupt(&self) -> bool { + false + } + + /// Owning thread for overlays scoped to a specific Codex thread. + fn thread_id(&self) -> Option { + None + } + + /// Drop interrupted-thread state while preserving queued work for other + /// threads when possible. Returns `true` if the view should remain visible. + fn dismiss_on_turn_interrupt(&mut self, interrupted_thread_id: ThreadId) -> bool { + self.preserve_on_turn_interrupt() + || self + .thread_id() + .is_some_and(|thread_id| thread_id != interrupted_thread_id) + } + + /// Drop stale state owned by a finished thread while preserving unrelated + /// or unscoped views. Returns `true` if the view should remain visible. + fn dismiss_on_thread_finished(&mut self, finished_thread_id: ThreadId) -> bool { + self.thread_id() != Some(finished_thread_id) + } } diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index e3b3287131c..bc0d2869e2a 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -472,6 +472,11 @@ impl ListSelectionView { self.apply_filter(); } + #[cfg(test)] + pub(crate) fn search_query_for_test(&self) -> &str { + &self.search_query + } + pub(crate) fn take_last_selected_index(&mut self) -> Option { self.last_selected_actual_idx.take() } diff --git a/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs index 975b8701d2d..fa7738544cc 100644 --- a/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs +++ b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs @@ -706,6 +706,15 @@ impl McpServerElicitationOverlay { overlay } + fn same_request_identity( + left: &McpServerElicitationFormRequest, + right: &McpServerElicitationFormRequest, + ) -> bool { + left.thread_id == right.thread_id + && left.server_name == right.server_name + && left.request_id == right.request_id + } + fn reset_for_request(&mut self) { self.answers = self .request @@ -1429,6 +1438,18 @@ impl Renderable for McpServerElicitationOverlay { } impl BottomPaneView for McpServerElicitationOverlay { + fn thread_id(&self) -> Option { + Some(self.request.thread_id) + } + + fn preserve_on_turn_interrupt(&self) -> bool { + true + } + + fn dismiss_on_thread_finished(&mut self, finished_thread_id: ThreadId) -> bool { + self.request.thread_id != finished_thread_id + } + fn prefer_esc_to_handle_key_event(&self) -> bool { true } @@ -1581,6 +1602,14 @@ impl BottomPaneView for McpServerElicitationOverlay { &mut self, request: McpServerElicitationFormRequest, ) -> Option { + if Self::same_request_identity(&self.request, &request) + || self + .queue + .iter() + .any(|queued| Self::same_request_identity(queued, &request)) + { + return None; + } self.queue.push_back(request); None } @@ -2254,6 +2283,39 @@ mod tests { assert_eq!(overlay.request.message, "Third"); } + #[test] + fn duplicate_request_is_not_queued_twice() { + let (tx, mut rx) = test_sender(); + let request = McpServerElicitationFormRequest::from_event( + ThreadId::default(), + form_request( + "Allow this request?", + serde_json::json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "boolean", + "title": "Confirm", + } + }, + }), + None, + ), + ) + .expect("expected supported form"); + let mut overlay = McpServerElicitationOverlay::new(request.clone(), tx, true, false, false); + + overlay.try_consume_mcp_server_elicitation_request(request); + overlay.submit_answers(); + + assert!(overlay.done, "expected duplicate request to be ignored"); + + let submit_count = std::iter::from_fn(|| rx.try_recv().ok()) + .filter(|event| matches!(event, AppEvent::SubmitThreadOp { .. })) + .count(); + assert_eq!(submit_count, 1); + } + #[test] fn boolean_form_snapshot() { let (tx, _rx) = test_sender(); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 2d66002418d..b402b695482 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -31,6 +31,7 @@ use codex_core::features::Features; use codex_core::plugins::PluginCapabilitySummary; use codex_core::skills::model::SkillMetadata; use codex_file_search::FileMatch; +use codex_protocol::ThreadId; use codex_protocol::request_user_input::RequestUserInputEvent; use codex_protocol::user_input::TextElement; use crossterm::event::KeyCode; @@ -59,6 +60,12 @@ pub(crate) use mcp_server_elicitation::McpServerElicitationOverlay; pub(crate) use request_user_input::RequestUserInputOverlay; mod bottom_pane_view; +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ThreadUserInputRequest { + pub(crate) thread_id: ThreadId, + pub(crate) request: RequestUserInputEvent, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct LocalImageAttachment { pub(crate) placeholder: String, @@ -806,10 +813,11 @@ impl BottomPane { } pub(crate) fn selected_index_for_active_view(&self, view_id: &'static str) -> Option { - self.view_stack - .last() - .filter(|view| view.view_id() == Some(view_id)) - .and_then(|view| view.selected_index()) + self.view_stack.last().and_then(|view| { + (view.view_id() == Some(view_id)) + .then(|| view.selected_index()) + .flatten() + }) } /// Update the pending-input preview shown above the composer. @@ -830,6 +838,37 @@ impl BottomPane { } } + /// Remove bottom-pane views owned by the interrupted thread unless they + /// explicitly outlive turn interrupts. Non-thread-scoped views are still + /// dismissed to preserve historical interrupt behavior. + pub(crate) fn dismiss_turn_scoped_views(&mut self, interrupted_thread_id: ThreadId) { + if self.view_stack.is_empty() { + return; + } + + self.view_stack + .retain_mut(|view| view.dismiss_on_turn_interrupt(interrupted_thread_id)); + if self.view_stack.is_empty() { + self.on_active_view_complete(); + } + self.request_redraw(); + } + + /// Remove stale bottom-pane views owned by a thread that has finished its + /// turn while preserving unrelated or unscoped overlays. + pub(crate) fn dismiss_finished_thread_views(&mut self, finished_thread_id: ThreadId) { + if self.view_stack.is_empty() { + return; + } + + self.view_stack + .retain_mut(|view| view.dismiss_on_thread_finished(finished_thread_id)); + if self.view_stack.is_empty() { + self.on_active_view_complete(); + } + self.request_redraw(); + } + #[cfg(test)] pub(crate) fn pending_thread_approvals(&self) -> &[String] { self.pending_thread_approvals.threads() @@ -921,7 +960,7 @@ impl BottomPane { } /// Called when the agent requests user input. - pub fn push_user_input_request(&mut self, request: RequestUserInputEvent) { + pub fn push_user_input_request(&mut self, request: ThreadUserInputRequest) { let request = if let Some(view) = self.view_stack.last_mut() { match view.try_consume_user_input_request(request) { Some(request) => request, @@ -1242,6 +1281,7 @@ mod tests { use ratatui::buffer::Buffer; use ratatui::layout::Rect; use std::cell::Cell; + use std::cell::RefCell; use std::path::PathBuf; use std::rc::Rc; use tokio::sync::mpsc::unbounded_channel; @@ -1959,4 +1999,192 @@ mod tests { assert_eq!(handle_calls.get(), 1); } + + #[test] + fn dismiss_turn_scoped_views_only_clears_interrupted_thread() { + struct TestView { + label: &'static str, + thread_id: Option, + preserve_on_interrupt: bool, + keep_on_interrupt: bool, + dropped: Rc>>, + } + + impl Drop for TestView { + fn drop(&mut self) { + self.dropped.borrow_mut().push(self.label); + } + } + + impl Renderable for TestView { + fn render(&self, _area: Rect, _buf: &mut Buffer) {} + + fn desired_height(&self, _width: u16) -> u16 { + 0 + } + } + + impl BottomPaneView for TestView { + fn preserve_on_turn_interrupt(&self) -> bool { + self.preserve_on_interrupt + } + + fn thread_id(&self) -> Option { + self.thread_id + } + + fn dismiss_on_turn_interrupt(&mut self, interrupted_thread_id: ThreadId) -> bool { + if self.keep_on_interrupt && self.thread_id == Some(interrupted_thread_id) { + self.thread_id = Some(ThreadId::new()); + return true; + } + self.preserve_on_turn_interrupt() + || self + .thread_id + .is_some_and(|thread_id| thread_id != interrupted_thread_id) + } + } + + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + let dropped = Rc::new(RefCell::new(Vec::new())); + let interrupted_thread_id = ThreadId::new(); + let other_thread_id = ThreadId::new(); + pane.push_view(Box::new(TestView { + label: "other-thread", + thread_id: Some(other_thread_id), + preserve_on_interrupt: false, + keep_on_interrupt: false, + dropped: Rc::clone(&dropped), + })); + pane.push_view(Box::new(TestView { + label: "preserved", + thread_id: Some(interrupted_thread_id), + preserve_on_interrupt: true, + keep_on_interrupt: false, + dropped: Rc::clone(&dropped), + })); + pane.push_view(Box::new(TestView { + label: "queued-other-thread", + thread_id: Some(interrupted_thread_id), + preserve_on_interrupt: false, + keep_on_interrupt: true, + dropped: Rc::clone(&dropped), + })); + pane.push_view(Box::new(TestView { + label: "interrupted-thread", + thread_id: Some(interrupted_thread_id), + preserve_on_interrupt: false, + keep_on_interrupt: false, + dropped: Rc::clone(&dropped), + })); + pane.push_view(Box::new(TestView { + label: "unscoped", + thread_id: None, + preserve_on_interrupt: false, + keep_on_interrupt: false, + dropped: Rc::clone(&dropped), + })); + + pane.dismiss_turn_scoped_views(interrupted_thread_id); + + assert_eq!(*dropped.borrow(), vec!["interrupted-thread", "unscoped"]); + assert!(pane.has_active_view()); + } + + #[test] + fn dismiss_finished_thread_views_only_clears_finished_thread() { + struct TestView { + label: &'static str, + thread_id: Option, + keep_on_finish: bool, + dropped: Rc>>, + } + + impl Drop for TestView { + fn drop(&mut self) { + self.dropped.borrow_mut().push(self.label); + } + } + + impl Renderable for TestView { + fn render(&self, _area: Rect, _buf: &mut Buffer) {} + + fn desired_height(&self, _width: u16) -> u16 { + 0 + } + } + + impl BottomPaneView for TestView { + fn thread_id(&self) -> Option { + self.thread_id + } + + fn dismiss_on_thread_finished(&mut self, finished_thread_id: ThreadId) -> bool { + if self.keep_on_finish && self.thread_id == Some(finished_thread_id) { + self.thread_id = Some(ThreadId::new()); + return true; + } + + self.thread_id != Some(finished_thread_id) + } + } + + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + let dropped = Rc::new(RefCell::new(Vec::new())); + let finished_thread_id = ThreadId::new(); + let other_thread_id = ThreadId::new(); + pane.push_view(Box::new(TestView { + label: "other-thread", + thread_id: Some(other_thread_id), + keep_on_finish: false, + dropped: Rc::clone(&dropped), + })); + pane.push_view(Box::new(TestView { + label: "keep-finished-thread", + thread_id: Some(finished_thread_id), + keep_on_finish: true, + dropped: Rc::clone(&dropped), + })); + pane.push_view(Box::new(TestView { + label: "finished-thread", + thread_id: Some(finished_thread_id), + keep_on_finish: false, + dropped: Rc::clone(&dropped), + })); + pane.push_view(Box::new(TestView { + label: "unscoped", + thread_id: None, + keep_on_finish: false, + dropped: Rc::clone(&dropped), + })); + + pane.dismiss_finished_thread_views(finished_thread_id); + + assert_eq!(*dropped.borrow(), vec!["finished-thread"]); + assert!(pane.has_active_view()); + } } diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index 79b1229800f..b3e004a8efe 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -23,6 +23,7 @@ use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::ChatComposer; use crate::bottom_pane::ChatComposerConfig; use crate::bottom_pane::InputResult; +use crate::bottom_pane::ThreadUserInputRequest; use crate::bottom_pane::bottom_pane_view::BottomPaneView; use crate::bottom_pane::scroll_state::ScrollState; use crate::bottom_pane::selection_popup_common::GenericDisplayRow; @@ -30,6 +31,7 @@ use crate::bottom_pane::selection_popup_common::measure_rows_height; use crate::history_cell; use crate::render::renderable::Renderable; +use codex_protocol::ThreadId; use codex_protocol::protocol::Op; use codex_protocol::request_user_input::RequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputEvent; @@ -120,9 +122,10 @@ impl FooterTip { pub(crate) struct RequestUserInputOverlay { app_event_tx: AppEventSender, + thread_id: ThreadId, request: RequestUserInputEvent, // Queue of incoming requests to process after the current one. - queue: VecDeque, + queue: VecDeque, // Reuse the shared chat composer so notes/freeform answers match the // primary input styling and behavior. composer: ChatComposer, @@ -137,7 +140,7 @@ pub(crate) struct RequestUserInputOverlay { impl RequestUserInputOverlay { pub(crate) fn new( - request: RequestUserInputEvent, + request: ThreadUserInputRequest, app_event_tx: AppEventSender, has_input_focus: bool, enhanced_keys_supported: bool, @@ -157,7 +160,8 @@ impl RequestUserInputOverlay { composer.set_footer_hint_override(Some(Vec::new())); let mut overlay = Self { app_event_tx, - request, + thread_id: request.thread_id, + request: request.request, queue: VecDeque::new(), composer, answers: Vec::new(), @@ -173,6 +177,15 @@ impl RequestUserInputOverlay { overlay } + fn same_request_identity( + left: &ThreadUserInputRequest, + right: &ThreadUserInputRequest, + ) -> bool { + left.thread_id == right.thread_id + && left.request.turn_id == right.request.turn_id + && left.request.call_id == right.request.call_id + } + fn current_index(&self) -> usize { self.current_idx } @@ -745,22 +758,36 @@ impl RequestUserInputOverlay { }, ); } - self.app_event_tx - .send(AppEvent::CodexOp(Op::UserInputAnswer { + self.app_event_tx.send(AppEvent::SubmitThreadOp { + thread_id: self.thread_id, + op: Op::UserInputAnswer { id: self.request.turn_id.clone(), response: RequestUserInputResponse { answers: answers.clone(), }, - })); - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::RequestUserInputResultCell { + }, + }); + self.app_event_tx.send(AppEvent::InsertThreadHistoryCell { + thread_id: self.thread_id, + cell: Box::new(history_cell::RequestUserInputResultCell { questions: self.request.questions.clone(), answers, interrupted: false, - }, - ))); + }), + }); + self.finish_current_request(); + } + + fn open_unanswered_confirmation(&mut self) { + let mut state = ScrollState::new(); + state.selected_idx = Some(0); + self.confirm_unanswered = Some(state); + } + + fn finish_current_request(&mut self) { if let Some(next) = self.queue.pop_front() { - self.request = next; + self.thread_id = next.thread_id; + self.request = next.request; self.reset_for_request(); self.ensure_focus_available(); self.restore_current_draft(); @@ -769,10 +796,35 @@ impl RequestUserInputOverlay { } } - fn open_unanswered_confirmation(&mut self) { - let mut state = ScrollState::new(); - state.selected_idx = Some(0); - self.confirm_unanswered = Some(state); + fn finish_next_request_matching( + &mut self, + mut keep: impl FnMut(&ThreadUserInputRequest) -> bool, + ) { + while let Some(next) = self.queue.pop_front() { + if !keep(&next) { + continue; + } + self.thread_id = next.thread_id; + self.request = next.request; + self.reset_for_request(); + self.ensure_focus_available(); + self.restore_current_draft(); + return; + } + self.done = true; + } + + fn interrupt_current_request(&mut self) { + let interrupted_thread_id = self.thread_id; + let interrupted_turn_id = self.request.turn_id.clone(); + self.app_event_tx.send(AppEvent::SubmitThreadOp { + thread_id: self.thread_id, + op: Op::Interrupt, + }); + self.finish_next_request_matching(|queued| { + !(queued.thread_id == interrupted_thread_id + && queued.request.turn_id == interrupted_turn_id) + }); } fn close_unanswered_confirmation(&mut self) { @@ -986,6 +1038,32 @@ impl RequestUserInputOverlay { } impl BottomPaneView for RequestUserInputOverlay { + fn thread_id(&self) -> Option { + Some(self.thread_id) + } + + fn dismiss_on_turn_interrupt(&mut self, interrupted_thread_id: ThreadId) -> bool { + self.queue + .retain(|queued| queued.thread_id != interrupted_thread_id); + + if self.thread_id == interrupted_thread_id { + self.finish_next_request_matching(|queued| queued.thread_id != interrupted_thread_id); + } + + !self.done + } + + fn dismiss_on_thread_finished(&mut self, finished_thread_id: ThreadId) -> bool { + self.queue + .retain(|queued| queued.thread_id != finished_thread_id); + + if self.thread_id == finished_thread_id { + self.finish_next_request_matching(|queued| queued.thread_id != finished_thread_id); + } + + !self.done + } + fn prefer_esc_to_handle_key_event(&self) -> bool { true } @@ -1007,8 +1085,7 @@ impl BottomPaneView for RequestUserInputOverlay { } // TODO: Emit interrupted request_user_input results (including committed answers) // once core supports persisting them reliably without follow-up turn issues. - self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt)); - self.done = true; + self.interrupt_current_request(); return; } @@ -1223,8 +1300,7 @@ impl BottomPaneView for RequestUserInputOverlay { self.close_unanswered_confirmation(); // TODO: Emit interrupted request_user_input results (including committed answers) // once core supports persisting them reliably without follow-up turn issues. - self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt)); - self.done = true; + self.interrupt_current_request(); return CancellationEvent::Handled; } if self.focus_is_notes() && !self.composer.current_text_with_pending().is_empty() { @@ -1234,8 +1310,7 @@ impl BottomPaneView for RequestUserInputOverlay { // TODO: Emit interrupted request_user_input results (including committed answers) // once core supports persisting them reliably without follow-up turn issues. - self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt)); - self.done = true; + self.interrupt_current_request(); CancellationEvent::Handled } @@ -1268,8 +1343,20 @@ impl BottomPaneView for RequestUserInputOverlay { fn try_consume_user_input_request( &mut self, - request: RequestUserInputEvent, - ) -> Option { + request: ThreadUserInputRequest, + ) -> Option { + let current = ThreadUserInputRequest { + thread_id: self.thread_id, + request: self.request.clone(), + }; + if Self::same_request_identity(¤t, &request) + || self + .queue + .iter() + .any(|queued| Self::same_request_identity(queued, &request)) + { + return None; + } self.queue.push_back(request); None } @@ -1298,11 +1385,19 @@ mod tests { (AppEventSender::new(tx_raw), rx) } - fn expect_interrupt_only(rx: &mut tokio::sync::mpsc::UnboundedReceiver) { + fn expect_interrupt_only( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, + thread_id: ThreadId, + ) { let event = rx.try_recv().expect("expected interrupt AppEvent"); - let AppEvent::CodexOp(op) = event else { - panic!("expected CodexOp"); + let AppEvent::SubmitThreadOp { + thread_id: op_thread_id, + op, + } = event + else { + panic!("expected SubmitThreadOp"); }; + assert_eq!(op_thread_id, thread_id); assert_eq!(op, Op::Interrupt); assert!( rx.try_recv().is_err(), @@ -1453,11 +1548,14 @@ mod tests { fn request_event( turn_id: &str, questions: Vec, - ) -> RequestUserInputEvent { - RequestUserInputEvent { - call_id: "call-1".to_string(), - turn_id: turn_id.to_string(), - questions, + ) -> ThreadUserInputRequest { + ThreadUserInputRequest { + thread_id: ThreadId::new(), + request: RequestUserInputEvent { + call_id: "call-1".to_string(), + turn_id: turn_id.to_string(), + questions, + }, } } @@ -1506,7 +1604,39 @@ mod tests { } #[test] - fn interrupt_discards_queued_requests_and_emits_interrupt() { + fn duplicate_request_is_not_queued_twice() { + let (tx, mut rx) = test_sender(); + let request = request_event("turn-1", vec![question_with_options("q1", "First")]); + let mut overlay = RequestUserInputOverlay::new(request.clone(), tx, true, false, false); + + overlay.try_consume_user_input_request(request); + overlay.submit_answers(); + + assert!(overlay.done, "expected duplicate request to be ignored"); + + let events: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect(); + let answer_count = events + .iter() + .filter(|event| { + matches!( + event, + AppEvent::SubmitThreadOp { + op: Op::UserInputAnswer { .. }, + .. + } + ) + }) + .count(); + let history_count = events + .iter() + .filter(|event| matches!(event, AppEvent::InsertThreadHistoryCell { .. })) + .count(); + assert_eq!(answer_count, 1); + assert_eq!(history_count, 1); + } + + #[test] + fn interrupt_advances_to_next_queued_request_and_emits_interrupt() { let (tx, mut rx) = test_sender(); let mut overlay = RequestUserInputOverlay::new( request_event("turn-1", vec![question_with_options("q1", "First")]), @@ -1515,21 +1645,72 @@ mod tests { false, false, ); - overlay.try_consume_user_input_request(RequestUserInputEvent { - call_id: "call-2".to_string(), - turn_id: "turn-2".to_string(), - questions: vec![question_with_options("q2", "Second")], + overlay.try_consume_user_input_request(ThreadUserInputRequest { + thread_id: ThreadId::new(), + request: RequestUserInputEvent { + call_id: "call-2".to_string(), + turn_id: "turn-2".to_string(), + questions: vec![question_with_options("q2", "Second")], + }, }); - overlay.try_consume_user_input_request(RequestUserInputEvent { - call_id: "call-3".to_string(), - turn_id: "turn-3".to_string(), - questions: vec![question_with_options("q3", "Third")], + overlay.try_consume_user_input_request(ThreadUserInputRequest { + thread_id: ThreadId::new(), + request: RequestUserInputEvent { + call_id: "call-3".to_string(), + turn_id: "turn-3".to_string(), + questions: vec![question_with_options("q3", "Third")], + }, }); + let interrupted_thread_id = overlay.thread_id; overlay.handle_key_event(KeyEvent::from(KeyCode::Esc)); - assert!(overlay.done, "expected overlay to be done"); - expect_interrupt_only(&mut rx); + assert!(!overlay.done, "expected queued requests to remain visible"); + assert_eq!(overlay.request.turn_id, "turn-2"); + assert_eq!(overlay.queue.len(), 1); + expect_interrupt_only(&mut rx, interrupted_thread_id); + } + + #[test] + fn interrupt_skips_queued_requests_from_same_turn() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_with_options("q1", "First")]), + tx, + true, + false, + false, + ); + let interrupted_thread_id = overlay.thread_id; + overlay.try_consume_user_input_request(ThreadUserInputRequest { + thread_id: interrupted_thread_id, + request: RequestUserInputEvent { + call_id: "call-2".to_string(), + turn_id: "turn-1".to_string(), + questions: vec![question_with_options("q2", "Second stale")], + }, + }); + overlay.try_consume_user_input_request(ThreadUserInputRequest { + thread_id: ThreadId::new(), + request: RequestUserInputEvent { + call_id: "call-3".to_string(), + turn_id: "turn-2".to_string(), + questions: vec![question_with_options("q3", "Third live")], + }, + }); + + overlay.handle_key_event(KeyEvent::from(KeyCode::Esc)); + + assert!( + !overlay.done, + "expected next live request to remain visible" + ); + assert_eq!(overlay.request.turn_id, "turn-2"); + assert!( + overlay.queue.is_empty(), + "expected stale same-turn request to be dropped" + ); + expect_interrupt_only(&mut rx, interrupted_thread_id); } #[test] @@ -1546,9 +1727,14 @@ mod tests { overlay.submit_answers(); let event = rx.try_recv().expect("expected AppEvent"); - let AppEvent::CodexOp(Op::UserInputAnswer { id, response, .. }) = event else { + let AppEvent::SubmitThreadOp { + thread_id, + op: Op::UserInputAnswer { id, response, .. }, + } = event + else { panic!("expected UserInputAnswer"); }; + assert_eq!(thread_id, overlay.thread_id); assert_eq!(id, "turn-1"); let answer = response.answers.get("q1").expect("answer missing"); assert_eq!(answer.answers, Vec::::new()); @@ -1568,9 +1754,14 @@ mod tests { overlay.handle_key_event(KeyEvent::from(KeyCode::Enter)); let event = rx.try_recv().expect("expected AppEvent"); - let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { + let AppEvent::SubmitThreadOp { + thread_id, + op: Op::UserInputAnswer { response, .. }, + } = event + else { panic!("expected UserInputAnswer"); }; + assert_eq!(thread_id, overlay.thread_id); let answer = response.answers.get("q1").expect("answer missing"); assert_eq!(answer.answers, vec!["Option 1".to_string()]); } @@ -1604,9 +1795,14 @@ mod tests { overlay.handle_key_event(KeyEvent::from(KeyCode::Enter)); let event = rx.try_recv().expect("expected AppEvent"); - let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { + let AppEvent::SubmitThreadOp { + thread_id, + op: Op::UserInputAnswer { response, .. }, + } = event + else { panic!("expected UserInputAnswer"); }; + assert_eq!(thread_id, overlay.thread_id); let mut expected = HashMap::new(); expected.insert( "q1".to_string(), @@ -1637,9 +1833,14 @@ mod tests { overlay.handle_key_event(KeyEvent::from(KeyCode::Char('2'))); let event = rx.try_recv().expect("expected AppEvent"); - let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { + let AppEvent::SubmitThreadOp { + thread_id, + op: Op::UserInputAnswer { response, .. }, + } = event + else { panic!("expected UserInputAnswer"); }; + assert_eq!(thread_id, overlay.thread_id); let answer = response.answers.get("q1").expect("answer missing"); assert_eq!(answer.answers, vec!["Option 2".to_string()]); } @@ -1887,9 +2088,14 @@ mod tests { overlay.handle_key_event(KeyEvent::from(KeyCode::Enter)); let event = rx.try_recv().expect("expected AppEvent"); - let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { + let AppEvent::SubmitThreadOp { + thread_id, + op: Op::UserInputAnswer { response, .. }, + } = event + else { panic!("expected UserInputAnswer"); }; + assert_eq!(thread_id, overlay.thread_id); let answer = response.answers.get("q1").expect("answer missing"); assert_eq!(answer.answers, Vec::::new()); let answer = response.answers.get("q2").expect("answer missing"); @@ -1910,7 +2116,7 @@ mod tests { overlay.handle_key_event(KeyEvent::from(KeyCode::Esc)); assert_eq!(overlay.done, true); - expect_interrupt_only(&mut rx); + expect_interrupt_only(&mut rx, overlay.thread_id); } #[test] @@ -1927,7 +2133,7 @@ mod tests { overlay.handle_key_event(KeyEvent::from(KeyCode::Esc)); assert_eq!(overlay.done, true); - expect_interrupt_only(&mut rx); + expect_interrupt_only(&mut rx, overlay.thread_id); } #[test] @@ -2012,7 +2218,7 @@ mod tests { overlay.handle_key_event(KeyEvent::from(KeyCode::Esc)); - expect_interrupt_only(&mut rx); + expect_interrupt_only(&mut rx, overlay.thread_id); } #[test] @@ -2187,9 +2393,14 @@ mod tests { overlay.submit_answers(); let event = rx.try_recv().expect("expected AppEvent"); - let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { + let AppEvent::SubmitThreadOp { + thread_id, + op: Op::UserInputAnswer { response, .. }, + } = event + else { panic!("expected UserInputAnswer"); }; + assert_eq!(thread_id, overlay.thread_id); let answer = response.answers.get("q1").expect("answer missing"); assert_eq!(answer.answers, Vec::::new()); } @@ -2212,9 +2423,14 @@ mod tests { overlay.submit_answers(); let event = rx.try_recv().expect("expected AppEvent"); - let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { + let AppEvent::SubmitThreadOp { + thread_id, + op: Op::UserInputAnswer { response, .. }, + } = event + else { panic!("expected UserInputAnswer"); }; + assert_eq!(thread_id, overlay.thread_id); let answer = response.answers.get("q1").expect("answer missing"); assert_eq!(answer.answers, Vec::::new()); } @@ -2255,9 +2471,14 @@ mod tests { overlay.submit_answers(); let event = rx.try_recv().expect("expected AppEvent"); - let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { + let AppEvent::SubmitThreadOp { + thread_id, + op: Op::UserInputAnswer { response, .. }, + } = event + else { panic!("expected UserInputAnswer"); }; + assert_eq!(thread_id, overlay.thread_id); let answer = response.answers.get("q1").expect("answer missing"); assert_eq!(answer.answers, Vec::::new()); } @@ -2291,9 +2512,14 @@ mod tests { overlay.submit_answers(); let event = rx.try_recv().expect("expected AppEvent"); - let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { + let AppEvent::SubmitThreadOp { + thread_id, + op: Op::UserInputAnswer { response, .. }, + } = event + else { panic!("expected UserInputAnswer"); }; + assert_eq!(thread_id, overlay.thread_id); let answer = response.answers.get("q1").expect("answer missing"); assert_eq!( answer.answers, @@ -2376,9 +2602,14 @@ mod tests { overlay.submit_answers(); let event = rx.try_recv().expect("expected AppEvent"); - let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { + let AppEvent::SubmitThreadOp { + thread_id, + op: Op::UserInputAnswer { response, .. }, + } = event + else { panic!("expected UserInputAnswer"); }; + assert_eq!(thread_id, overlay.thread_id); let answer = response.answers.get("q1").expect("answer missing"); assert_eq!( answer.answers, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 7f3831a4040..3a49ba57615 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -51,6 +51,7 @@ use crate::status::rate_limit_snapshot_display_for_limit; use crate::text_formatting::proper_join; use crate::version::CODEX_CLI_VERSION; use codex_app_server_protocol::ConfigLayerSource; +use codex_arg0::Arg0DispatchPaths; use codex_backend_client::Client as BackendClient; use codex_chatgpt::connectors; use codex_core::config::Config; @@ -58,6 +59,7 @@ use codex_core::config::Constrained; use codex_core::config::ConstraintResult; use codex_core::config::types::Notifications; use codex_core::config::types::WindowsSandboxModeToml; +use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::ConfigLayerStackOrdering; use codex_core::features::FEATURES; use codex_core::features::Feature; @@ -164,6 +166,7 @@ use ratatui::widgets::Paragraph; use ratatui::widgets::Wrap; use tokio::sync::mpsc::UnboundedSender; use tokio::task::JoinHandle; +use toml::Value as TomlValue; use tracing::debug; use tracing::warn; @@ -233,6 +236,7 @@ use crate::bottom_pane::QUIT_SHORTCUT_TIMEOUT; use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::ThreadUserInputRequest; use crate::bottom_pane::custom_prompt_view::CustomPromptView; use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::clipboard_paste::paste_image_to_temp_png; @@ -269,8 +273,8 @@ use crate::tui::FrameRequester; mod interrupts; use self::interrupts::InterruptManager; mod agent; +pub(crate) use self::agent::ThreadScopedOp; use self::agent::spawn_agent; -use self::agent::spawn_agent_from_existing; pub(crate) use self::agent::spawn_op_forwarder; mod session_header; use self::session_header::SessionHeader; @@ -465,7 +469,19 @@ pub(crate) fn get_limits_duration(windows_minutes: i64) -> String { } } -/// Common initialization parameters shared by all `ChatWidget` constructors. +/// Ambient state needed to start an in-process app-server client inside the +/// agent task. Cloned into each spawned agent so new threads/forks can create +/// their own `InProcessAppServerClient` without reaching back to the TUI shell. +#[derive(Clone)] +pub(crate) struct InProcessAgentContext { + pub(crate) arg0_paths: Arg0DispatchPaths, + pub(crate) cli_kv_overrides: Vec<(String, TomlValue)>, + pub(crate) cloud_requirements: CloudRequirementsLoader, +} + +/// Common initialization parameters shared by all `ChatWidget` constructors +/// (fresh session, resume, fork). Bundled into a struct to keep constructor +/// signatures manageable. pub(crate) struct ChatWidgetInit { pub(crate) config: Config, pub(crate) frame_requester: FrameRequester, @@ -482,6 +498,7 @@ pub(crate) struct ChatWidgetInit { // Shared latch so we only warn once about invalid status-line item IDs. pub(crate) status_line_invalid_items_warned: Arc, pub(crate) session_telemetry: SessionTelemetry, + pub(crate) in_process_context: InProcessAgentContext, } #[derive(Default)] @@ -542,6 +559,8 @@ pub(crate) enum ExternalEditorState { pub(crate) struct ChatWidget { app_event_tx: AppEventSender, codex_op_tx: UnboundedSender, + thread_scoped_op_tx: Option>, + app_server_thread_id: Option, bottom_pane: BottomPane, active_cell: Option>, /// Monotonic-ish counter used to invalidate transcript overlay caching. @@ -618,6 +637,7 @@ pub(crate) struct ChatWidget { pending_status_indicator_restore: bool, suppress_queue_autosend: bool, thread_id: Option, + current_turn_id: Option, thread_name: Option, forked_from: Option, frame_requester: FrameRequester, @@ -2052,6 +2072,12 @@ impl ChatWidget { self.finalize_turn(); if reason == TurnAbortReason::Interrupted { self.clear_unified_exec_processes(); + // The in-process agent clears pending turn-scoped approvals as part of turn abort + // handling. Preserve MCP elicitation overlays because those requests can outlive the + // interrupted turn and still need a local response. + if let Some(thread_id) = self.thread_id { + self.bottom_pane.dismiss_turn_scoped_views(thread_id); + } } let send_pending_steers_immediately = self.submit_pending_steers_after_interrupt; self.submit_pending_steers_after_interrupt = false; @@ -2232,42 +2258,72 @@ impl ChatWidget { } fn on_exec_approval_request(&mut self, _id: String, ev: ExecApprovalRequestEvent) { + let Some(thread_id) = self.thread_id() else { + tracing::warn!("dropping exec approval event before session configured"); + return; + }; let ev2 = ev.clone(); self.defer_or_handle( - |q| q.push_exec_approval(ev), - |s| s.handle_exec_approval_now(ev2), + |q| q.push_exec_approval(thread_id, ev), + |s| s.handle_exec_approval_for_thread(thread_id, ev2), ); } fn on_apply_patch_approval_request(&mut self, _id: String, ev: ApplyPatchApprovalRequestEvent) { + let Some(thread_id) = self.thread_id() else { + tracing::warn!("dropping apply_patch approval event before session configured"); + return; + }; let ev2 = ev.clone(); self.defer_or_handle( - |q| q.push_apply_patch_approval(ev), - |s| s.handle_apply_patch_approval_now(ev2), + |q| q.push_apply_patch_approval(thread_id, ev), + |s| s.handle_apply_patch_approval_for_thread(thread_id, ev2), ); } fn on_elicitation_request(&mut self, ev: ElicitationRequestEvent) { + let Some(thread_id) = self.thread_id() else { + tracing::warn!("dropping elicitation request before session configured"); + return; + }; let ev2 = ev.clone(); self.defer_or_handle( - |q| q.push_elicitation(ev), - |s| s.handle_elicitation_request_now(ev2), + |q| q.push_elicitation(thread_id, ev), + |s| s.handle_elicitation_request_for_thread(thread_id, ev2), ); } - fn on_request_user_input(&mut self, ev: RequestUserInputEvent) { + fn on_request_permissions(&mut self, ev: RequestPermissionsEvent) { + let Some(thread_id) = self.thread_id() else { + tracing::warn!("dropping request_permissions event before session configured"); + return; + }; let ev2 = ev.clone(); self.defer_or_handle( - |q| q.push_user_input(ev), - |s| s.handle_request_user_input_now(ev2), + |q| q.push_request_permissions(thread_id, ev), + |s| s.handle_request_permissions_for_thread(thread_id, ev2), ); } - fn on_request_permissions(&mut self, ev: RequestPermissionsEvent) { + fn on_request_user_input(&mut self, ev: RequestUserInputEvent) { + let Some(thread_id) = self.thread_id() else { + tracing::warn!("dropping request_user_input event before session configured"); + return; + }; let ev2 = ev.clone(); self.defer_or_handle( - |q| q.push_request_permissions(ev), - |s| s.handle_request_permissions_now(ev2), + |q| { + q.push_user_input(ThreadUserInputRequest { + thread_id, + request: ev, + }) + }, + |s| { + s.handle_request_user_input_now(ThreadUserInputRequest { + thread_id, + request: ev2, + }) + }, ); } @@ -2951,7 +3007,11 @@ impl ChatWidget { self.had_work_activity = true; } - pub(crate) fn handle_exec_approval_now(&mut self, ev: ExecApprovalRequestEvent) { + pub(crate) fn handle_exec_approval_for_thread( + &mut self, + thread_id: ThreadId, + ev: ExecApprovalRequestEvent, + ) { self.flush_answer_stream_with_separator(); let command = shlex::try_join(ev.command.iter().map(String::as_str)) .unwrap_or_else(|_| ev.command.join(" ")); @@ -2959,7 +3019,7 @@ impl ChatWidget { let available_decisions = ev.effective_available_decisions(); let request = ApprovalRequest::Exec { - thread_id: self.thread_id.unwrap_or_default(), + thread_id, thread_label: None, id: ev.effective_approval_id(), command: ev.command, @@ -2973,11 +3033,15 @@ impl ChatWidget { self.request_redraw(); } - pub(crate) fn handle_apply_patch_approval_now(&mut self, ev: ApplyPatchApprovalRequestEvent) { + pub(crate) fn handle_apply_patch_approval_for_thread( + &mut self, + thread_id: ThreadId, + ev: ApplyPatchApprovalRequestEvent, + ) { self.flush_answer_stream_with_separator(); let request = ApprovalRequest::ApplyPatch { - thread_id: self.thread_id.unwrap_or_default(), + thread_id, thread_label: None, id: ev.call_id, reason: ev.reason, @@ -2993,14 +3057,17 @@ impl ChatWidget { }); } - pub(crate) fn handle_elicitation_request_now(&mut self, ev: ElicitationRequestEvent) { + pub(crate) fn handle_elicitation_request_for_thread( + &mut self, + thread_id: ThreadId, + ev: ElicitationRequestEvent, + ) { self.flush_answer_stream_with_separator(); self.notify(Notification::ElicitationRequested { server_name: ev.server_name.clone(), }); - let thread_id = self.thread_id.unwrap_or_default(); if let Some(request) = McpServerElicitationFormRequest::from_event(thread_id, ev.clone()) { self.bottom_pane .push_mcp_server_elicitation_request(request); @@ -3018,6 +3085,31 @@ impl ChatWidget { self.request_redraw(); } + pub(crate) fn handle_request_permissions_for_thread( + &mut self, + thread_id: ThreadId, + ev: RequestPermissionsEvent, + ) { + self.flush_answer_stream_with_separator(); + self.notify(Notification::PermissionsRequested { + summary: ev + .reason + .clone() + .or_else(|| Notification::permission_request_summary(&ev.permissions)), + }); + self.bottom_pane.push_approval_request( + ApprovalRequest::Permissions { + thread_id, + thread_label: None, + call_id: ev.call_id, + reason: ev.reason, + permissions: ev.permissions, + }, + &self.config.features, + ); + self.request_redraw(); + } + pub(crate) fn push_approval_request(&mut self, request: ApprovalRequest) { self.bottom_pane .push_approval_request(request, &self.config.features); @@ -3033,27 +3125,23 @@ impl ChatWidget { self.request_redraw(); } - pub(crate) fn handle_request_user_input_now(&mut self, ev: RequestUserInputEvent) { - self.flush_answer_stream_with_separator(); - self.notify(Notification::UserInputRequested { - question_count: ev.questions.len(), - summary: Notification::user_input_request_summary(&ev.questions), - }); - self.bottom_pane.push_user_input_request(ev); + pub(crate) fn push_user_input_request(&mut self, request: ThreadUserInputRequest) { + self.bottom_pane.push_user_input_request(request); self.request_redraw(); } - pub(crate) fn handle_request_permissions_now(&mut self, ev: RequestPermissionsEvent) { + pub(crate) fn dismiss_finished_thread_views(&mut self, thread_id: ThreadId) { + self.bottom_pane.dismiss_finished_thread_views(thread_id); + self.request_redraw(); + } + + pub(crate) fn handle_request_user_input_now(&mut self, ev: ThreadUserInputRequest) { self.flush_answer_stream_with_separator(); - let request = ApprovalRequest::Permissions { - thread_id: self.thread_id.unwrap_or_default(), - thread_label: None, - call_id: ev.call_id, - reason: ev.reason, - permissions: ev.permissions, - }; - self.bottom_pane - .push_approval_request(request, &self.config.features); + self.notify(Notification::UserInputRequested { + question_count: ev.request.questions.len(), + summary: Notification::user_input_request_summary(&ev.request.questions), + }); + self.bottom_pane.push_user_input_request(ev); self.request_redraw(); } @@ -3185,6 +3273,7 @@ impl ChatWidget { startup_tooltip_override, status_line_invalid_items_warned, session_telemetry, + in_process_context, } = common; let model = model.filter(|m| !m.trim().is_empty()); let mut config = config; @@ -3192,7 +3281,12 @@ impl ChatWidget { let prevent_idle_sleep = config.features.enabled(Feature::PreventIdleSleep); let mut rng = rand::rng(); let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string(); - let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), thread_manager); + let (codex_op_tx, thread_scoped_op_tx) = spawn_agent( + config.clone(), + app_event_tx.clone(), + thread_manager, + in_process_context, + ); let model_override = model.as_deref(); let model_for_header = model @@ -3224,6 +3318,8 @@ impl ChatWidget { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), codex_op_tx, + thread_scoped_op_tx: Some(thread_scoped_op_tx), + app_server_thread_id: None, bottom_pane: BottomPane::new(BottomPaneParams { frame_requester, app_event_tx, @@ -3278,6 +3374,7 @@ impl ChatWidget { pending_status_indicator_restore: false, suppress_queue_autosend: false, thread_id: None, + current_turn_id: None, thread_name: None, forked_from: None, queued_user_messages: VecDeque::new(), @@ -3355,6 +3452,8 @@ impl ChatWidget { pub(crate) fn new_with_op_sender( common: ChatWidgetInit, codex_op_tx: UnboundedSender, + thread_scoped_op_tx: Option>, + app_server_thread_id: Option, ) -> Self { let ChatWidgetInit { config, @@ -3371,6 +3470,7 @@ impl ChatWidget { startup_tooltip_override, status_line_invalid_items_warned, session_telemetry, + in_process_context: _, } = common; let model = model.filter(|m| !m.trim().is_empty()); let mut config = config; @@ -3409,6 +3509,8 @@ impl ChatWidget { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), codex_op_tx, + thread_scoped_op_tx, + app_server_thread_id, bottom_pane: BottomPane::new(BottomPaneParams { frame_requester, app_event_tx, @@ -3463,6 +3565,7 @@ impl ChatWidget { pending_status_indicator_restore: false, suppress_queue_autosend: false, thread_id: None, + current_turn_id: None, thread_name: None, forked_from: None, saw_plan_update_this_turn: false, @@ -3548,6 +3651,7 @@ impl ChatWidget { startup_tooltip_override: _, status_line_invalid_items_warned, session_telemetry, + in_process_context: _, } = common; let model = model.filter(|m| !m.trim().is_empty()); let prevent_idle_sleep = config.features.enabled(Feature::PreventIdleSleep); @@ -3565,9 +3669,8 @@ impl ChatWidget { .and_then(|mask| mask.model.clone()) .unwrap_or(header_model); - let current_cwd = Some(session_configured.cwd.clone()); - let codex_op_tx = - spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone()); + let current_cwd = Some(session_configured.cwd); + let codex_op_tx = spawn_op_forwarder(conversation); let fallback_default = Settings { model: header_model.clone(), @@ -3586,6 +3689,8 @@ impl ChatWidget { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), codex_op_tx, + thread_scoped_op_tx: None, + app_server_thread_id: None, bottom_pane: BottomPane::new(BottomPaneParams { frame_requester, app_event_tx, @@ -3640,6 +3745,7 @@ impl ChatWidget { pending_status_indicator_restore: false, suppress_queue_autosend: false, thread_id: None, + current_turn_id: None, thread_name: None, forked_from: None, queued_user_messages: VecDeque::new(), @@ -4903,6 +5009,7 @@ impl ChatWidget { } EventMsg::AgentReasoningSectionBreak(_) => self.on_reasoning_section_break(), EventMsg::TurnStarted(event) => { + self.current_turn_id = Some(event.turn_id.clone()); if !is_resume_initial_replay { self.apply_turn_started_context_window(event.model_context_window); self.on_task_started(); @@ -4910,7 +5017,10 @@ impl ChatWidget { } EventMsg::TurnComplete(TurnCompleteEvent { last_agent_message, .. - }) => self.on_task_complete(last_agent_message, from_replay), + }) => { + self.current_turn_id = None; + self.on_task_complete(last_agent_message, from_replay); + } EventMsg::TokenCount(ev) => { self.set_token_info(ev.info); self.on_rate_limit_snapshot(ev.rate_limits); @@ -4940,15 +5050,18 @@ impl ChatWidget { EventMsg::McpStartupComplete(ev) => self.on_mcp_startup_complete(ev), EventMsg::TurnAborted(ev) => match ev.reason { TurnAbortReason::Interrupted => { + self.current_turn_id = None; self.on_interrupted_turn(ev.reason); } TurnAbortReason::Replaced => { + self.current_turn_id = None; self.submit_pending_steers_after_interrupt = false; self.pending_steers.clear(); self.refresh_pending_input_preview(); self.on_error("Turn aborted: replaced by a new task".to_owned()) } TurnAbortReason::ReviewEnded => { + self.current_turn_id = None; self.on_interrupted_turn(ev.reason); } }, @@ -4993,7 +5106,10 @@ impl ChatWidget { force_reload: true, }); } - EventMsg::ShutdownComplete => self.on_shutdown_complete(), + EventMsg::ShutdownComplete => { + self.current_turn_id = None; + self.on_shutdown_complete(); + } EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => self.on_turn_diff(unified_diff), EventMsg::DeprecationNotice(ev) => self.on_deprecation_notice(ev), EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => { @@ -5050,14 +5166,14 @@ impl ChatWidget { EventMsg::CollabCloseEnd(ev) => self.on_collab_event(multi_agents::close_end(ev)), EventMsg::CollabResumeBegin(ev) => self.on_collab_event(multi_agents::resume_begin(ev)), EventMsg::CollabResumeEnd(ev) => self.on_collab_event(multi_agents::resume_end(ev)), - EventMsg::ThreadRolledBack(rollback) => { + EventMsg::ThreadRolledBack(_rollback) => { // Conservatively clear `/copy` state on rollback. The app layer trims visible // transcript cells, but we do not maintain rollback-aware raw-markdown history yet, // so keeping the previous cache can return content that was just removed. self.last_copyable_output = None; if from_replay { self.app_event_tx.send(AppEvent::ApplyThreadRollback { - num_turns: rollback.num_turns, + num_turns: _rollback.num_turns, }); } } @@ -8353,6 +8469,22 @@ impl ChatWidget { if matches!(&op, Op::Review { .. }) && !self.bottom_pane.is_task_running() { self.bottom_pane.set_task_running(true); } + if let (Some(thread_id), Some(thread_scoped_op_tx)) = + (self.app_server_thread_id, self.thread_scoped_op_tx.as_ref()) + { + let interrupt_turn_id = matches!(&op, Op::Interrupt) + .then(|| self.current_turn_id.clone()) + .flatten(); + if let Err(e) = thread_scoped_op_tx.send(ThreadScopedOp { + thread_id, + op, + interrupt_turn_id, + }) { + tracing::error!("failed to submit thread-scoped op: {e}"); + return false; + } + return true; + } if let Err(e) = self.codex_op_tx.send(op) { tracing::error!("failed to submit op: {e}"); return false; @@ -8360,6 +8492,12 @@ impl ChatWidget { true } + pub(crate) fn thread_scoped_op_sender( + &self, + ) -> Option> { + self.thread_scoped_op_tx.clone() + } + fn on_list_mcp_tools(&mut self, ev: McpListToolsResponseEvent) { self.add_to_history(history_cell::new_mcp_tools_output( &self.config, @@ -8802,6 +8940,9 @@ enum Notification { ElicitationRequested { server_name: String, }, + PermissionsRequested { + summary: Option, + }, PlanModePrompt { title: String, }, @@ -8835,6 +8976,10 @@ impl Notification { Notification::ElicitationRequested { server_name } => { format!("Approval requested by {server_name}") } + Notification::PermissionsRequested { summary } => summary + .as_deref() + .map(|summary| format!("Permissions requested: {summary}")) + .unwrap_or_else(|| "Permissions requested".to_string()), Notification::PlanModePrompt { title } => { format!("Plan mode prompt: {title}") } @@ -8854,7 +8999,8 @@ impl Notification { Notification::AgentTurnComplete { .. } => "agent-turn-complete", Notification::ExecApprovalRequested { .. } | Notification::EditApprovalRequested { .. } - | Notification::ElicitationRequested { .. } => "approval-requested", + | Notification::ElicitationRequested { .. } + | Notification::PermissionsRequested { .. } => "approval-requested", Notification::PlanModePrompt { .. } => "plan-mode-prompt", Notification::UserInputRequested { .. } => "user-input-requested", } @@ -8866,6 +9012,7 @@ impl Notification { Notification::ExecApprovalRequested { .. } | Notification::EditApprovalRequested { .. } | Notification::ElicitationRequested { .. } + | Notification::PermissionsRequested { .. } | Notification::PlanModePrompt { .. } | Notification::UserInputRequested { .. } => 1, } @@ -8909,6 +9056,46 @@ impl Notification { Some(truncate_text(summary, 30)) } } + + fn permission_request_summary( + permissions: &codex_protocol::models::PermissionProfile, + ) -> Option { + let mut parts = Vec::new(); + if permissions + .network + .as_ref() + .and_then(|network| network.enabled) + .unwrap_or(false) + { + parts.push("network".to_string()); + } + if let Some(file_system) = permissions.file_system.as_ref() { + let read_count = file_system.read.as_ref().map(Vec::len).unwrap_or(0); + let write_count = file_system.write.as_ref().map(Vec::len).unwrap_or(0); + if read_count > 0 { + parts.push(if read_count == 1 { + "1 read root".to_string() + } else { + format!("{read_count} read roots") + }); + } + if write_count > 0 { + parts.push(if write_count == 1 { + "1 write root".to_string() + } else { + format!("{write_count} write roots") + }); + } + } + if permissions.macos.is_some() { + parts.push("macOS access".to_string()); + } + if parts.is_empty() { + None + } else { + Some(truncate_text(&parts.join(", "), 30)) + } + } } const AGENT_NOTIFICATION_PREVIEW_GRAPHEMES: usize = 200; diff --git a/codex-rs/tui/src/chatwidget/agent.rs b/codex-rs/tui/src/chatwidget/agent.rs index e14a9e3628a..ea707377502 100644 --- a/codex-rs/tui/src/chatwidget/agent.rs +++ b/codex-rs/tui/src/chatwidget/agent.rs @@ -1,19 +1,248 @@ +//! In-process app-server agent for the TUI. +//! +//! This module owns the background task that bridges the TUI's `Op`-driven +//! command model and the app-server's JSON-RPC protocol. On startup it creates +//! an [`InProcessAppServerClient`], opens a thread via `thread/start`, and then +//! enters a `select!` loop that: +//! +//! 1. Receives `Op` values from the `ChatWidget` and translates them into +//! app-server client requests (`turn/start`, `turn/interrupt`, approvals, +//! etc.), while forwarding a small set of legacy thread ops directly to the +//! backing `CodexThread` until app-server grows first-class equivalents. +//! 2. Receives server events (`ServerRequest`, `ServerNotification`, legacy +//! `JSONRPCNotification`) from the app-server and converts them into +//! `EventMsg` values that the TUI already knows how to render. +//! +//! The module also contains local history I/O, protocol-type conversion +//! helpers, and the `spawn_op_forwarder` used for resumed/forked threads that +//! bypass the in-process client. + +use std::collections::HashMap; +use std::collections::HashSet; +use std::collections::VecDeque; +use std::fs::OpenOptions; +use std::future::Future; +use std::io::BufRead; +use std::io::BufReader; +use std::io::Read; +use std::io::Seek; +use std::io::SeekFrom; +use std::io::Write; +use std::path::PathBuf; use std::sync::Arc; +use std::time::Duration; +use codex_app_server_client::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY; +use codex_app_server_client::InProcessAppServerClient; +use codex_app_server_client::InProcessClientStartArgs; +use codex_app_server_client::InProcessServerEvent; +use codex_app_server_protocol::ApplyPatchApprovalResponse; +use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::CommandExecutionApprovalDecision; +use codex_app_server_protocol::CommandExecutionRequestApprovalResponse; +use codex_app_server_protocol::ConfigWarningNotification; +use codex_app_server_protocol::DynamicToolCallOutputContentItem; +use codex_app_server_protocol::DynamicToolCallResponse; +use codex_app_server_protocol::ExecCommandApprovalResponse; +use codex_app_server_protocol::FileChangeApprovalDecision; +use codex_app_server_protocol::FileChangeRequestApprovalResponse; +use codex_app_server_protocol::GrantedMacOsPermissions; +use codex_app_server_protocol::GrantedPermissionProfile; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::McpServerElicitationAction; +use codex_app_server_protocol::McpServerElicitationRequest; +use codex_app_server_protocol::McpServerElicitationRequestResponse; +use codex_app_server_protocol::McpServerRefreshResponse; +use codex_app_server_protocol::ModelListParams; +use codex_app_server_protocol::ModelListResponse; +use codex_app_server_protocol::PatchChangeKind; +use codex_app_server_protocol::PermissionsRequestApprovalResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ReviewDelivery; +use codex_app_server_protocol::ReviewStartParams; +use codex_app_server_protocol::ReviewStartResponse; +use codex_app_server_protocol::ReviewTarget as ApiReviewTarget; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::SkillsListResponse; +use codex_app_server_protocol::SkillsRemoteReadResponse; +use codex_app_server_protocol::SkillsRemoteWriteResponse; +use codex_app_server_protocol::ThreadBackgroundTerminalsCleanResponse; +use codex_app_server_protocol::ThreadCompactStartResponse; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadRealtimeAppendAudioResponse; +use codex_app_server_protocol::ThreadRealtimeAppendTextResponse; +use codex_app_server_protocol::ThreadRealtimeStartResponse; +use codex_app_server_protocol::ThreadRealtimeStopResponse; +use codex_app_server_protocol::ThreadRollbackResponse; +use codex_app_server_protocol::ThreadSetNameResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::ThreadUnsubscribeParams; +use codex_app_server_protocol::ThreadUnsubscribeResponse; +use codex_app_server_protocol::ToolRequestUserInputAnswer; +use codex_app_server_protocol::ToolRequestUserInputResponse; +use codex_app_server_protocol::TurnInterruptParams; +use codex_app_server_protocol::TurnInterruptResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::TurnStatus; use codex_core::CodexThread; -use codex_core::NewThread; use codex_core::ThreadManager; +use codex_core::auth::AuthManager; use codex_core::config::Config; +use codex_core::config::types::HistoryPersistence; +use codex_core::config_loader::CloudRequirementsLoader; +use codex_core::config_loader::LoaderOverrides; +use codex_feedback::CodexFeedback; +use codex_protocol::ThreadId; +use codex_protocol::account::PlanType as AccountPlanType; +use codex_protocol::approvals::ApplyPatchApprovalRequestEvent; +use codex_protocol::approvals::ElicitationRequestEvent; +use codex_protocol::approvals::ExecApprovalRequestEvent; +use codex_protocol::dynamic_tools::DynamicToolCallRequest; +use codex_protocol::models::FileSystemPermissions; +use codex_protocol::models::MacOsSeatbeltProfileExtensions; +use codex_protocol::models::NetworkPermissions; +use codex_protocol::models::PermissionProfile; +use codex_protocol::parse_command::ParsedCommand; +use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::FileChange; +use codex_protocol::protocol::GetHistoryEntryResponseEvent; +use codex_protocol::protocol::ListCustomPromptsResponseEvent; +use codex_protocol::protocol::ListRemoteSkillsResponseEvent; +use codex_protocol::protocol::ListSkillsResponseEvent; use codex_protocol::protocol::Op; +use codex_protocol::protocol::RemoteSkillDownloadedEvent; +use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; +use codex_protocol::protocol::SessionConfiguredEvent; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::WarningEvent; +use codex_protocol::request_permissions::RequestPermissionsEvent; +use codex_protocol::request_user_input::RequestUserInputEvent; +use tokio::io::AsyncReadExt; use tokio::sync::mpsc::UnboundedSender; use tokio::sync::mpsc::unbounded_channel; +use toml::Value as TomlValue; +use tracing::warn; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; +use crate::chatwidget::InProcessAgentContext; +use crate::version::CODEX_CLI_VERSION; + +#[cfg(test)] +use codex_app_server_protocol::ChatgptAuthTokensRefreshParams; +#[cfg(test)] +use codex_app_server_protocol::ChatgptAuthTokensRefreshReason; +#[cfg(test)] +use codex_app_server_protocol::CommandExecutionRequestApprovalParams; +#[cfg(test)] +use codex_app_server_protocol::DynamicToolCallParams; +#[cfg(test)] +use codex_app_server_protocol::ExecCommandApprovalParams; +#[cfg(test)] +use codex_app_server_protocol::FileChangeRequestApprovalParams; +#[cfg(test)] +use codex_app_server_protocol::McpElicitationObjectType; +#[cfg(test)] +use codex_app_server_protocol::McpElicitationSchema; +#[cfg(test)] +use codex_app_server_protocol::McpServerElicitationRequestParams; +#[cfg(test)] +use codex_app_server_protocol::PermissionsRequestApprovalParams; +#[cfg(test)] +use codex_app_server_protocol::ToolRequestUserInputOption; +#[cfg(test)] +use codex_app_server_protocol::ToolRequestUserInputParams; +#[cfg(test)] +use codex_app_server_protocol::ToolRequestUserInputQuestion; const TUI_NOTIFY_CLIENT: &str = "codex-tui"; +const HISTORY_FILENAME: &str = "history.jsonl"; +const HISTORY_SOFT_CAP_RATIO: f64 = 0.8; +const HISTORY_LOCK_MAX_RETRIES: usize = 10; +const HISTORY_LOCK_RETRY_SLEEP: Duration = Duration::from_millis(100); + +/// Interactive request types that the in-process app-server delivers as typed +/// `ServerRequest` variants instead of legacy `codex/event/…` notifications. +/// +/// This enum is the single source of truth for the opt-out list passed to the +/// app-server at startup. When a new interactive request type is promoted from +/// the legacy notification path to a typed request, add it here so the +/// app-server stops sending the duplicate legacy notification. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum InProcessTypedInteractiveRequest { + ExecApproval, + ApplyPatchApproval, + RequestPermissions, + RequestUserInput, + McpServerElicitation, + DynamicToolCall, +} + +impl InProcessTypedInteractiveRequest { + const ALL: [Self; 6] = [ + Self::ExecApproval, + Self::ApplyPatchApproval, + Self::RequestPermissions, + Self::RequestUserInput, + Self::McpServerElicitation, + Self::DynamicToolCall, + ]; + + fn legacy_notification_method(self) -> &'static str { + match self { + Self::ExecApproval => "codex/event/exec_approval_request", + Self::ApplyPatchApproval => "codex/event/apply_patch_approval_request", + Self::RequestPermissions => "codex/event/request_permissions", + Self::RequestUserInput => "codex/event/request_user_input", + Self::McpServerElicitation => "codex/event/elicitation_request", + Self::DynamicToolCall => "codex/event/dynamic_tool_call_request", + } + } +} + +#[cfg(test)] +fn in_process_typed_interactive_request( + request: &ServerRequest, +) -> Option { + match request { + ServerRequest::CommandExecutionRequestApproval { .. } + | ServerRequest::ExecCommandApproval { .. } => { + Some(InProcessTypedInteractiveRequest::ExecApproval) + } + ServerRequest::FileChangeRequestApproval { .. } + | ServerRequest::ApplyPatchApproval { .. } => { + Some(InProcessTypedInteractiveRequest::ApplyPatchApproval) + } + ServerRequest::PermissionsRequestApproval { .. } => { + Some(InProcessTypedInteractiveRequest::RequestPermissions) + } + ServerRequest::ToolRequestUserInput { .. } => { + Some(InProcessTypedInteractiveRequest::RequestUserInput) + } + ServerRequest::McpServerElicitationRequest { .. } => { + Some(InProcessTypedInteractiveRequest::McpServerElicitation) + } + ServerRequest::DynamicToolCall { .. } => { + Some(InProcessTypedInteractiveRequest::DynamicToolCall) + } + ServerRequest::ChatgptAuthTokensRefresh { .. } => None, + } +} + +fn in_process_typed_event_legacy_opt_outs() -> Vec { + InProcessTypedInteractiveRequest::ALL + .into_iter() + .map(InProcessTypedInteractiveRequest::legacy_notification_method) + .map(str::to_string) + .collect() +} async fn initialize_app_server_client_name(thread: &CodexThread) { if let Err(err) = thread @@ -24,126 +253,4865 @@ async fn initialize_app_server_client_name(thread: &CodexThread) { } } -/// Spawn the agent bootstrapper and op forwarding loop, returning the -/// `UnboundedSender` used by the UI to submit operations. -pub(crate) fn spawn_agent( - config: Config, - app_event_tx: AppEventSender, - server: Arc, -) -> UnboundedSender { - let (codex_op_tx, mut codex_op_rx) = unbounded_channel::(); +/// Build the initialization payload for an in-process app-server client from +/// the TUI's runtime state. The resulting client embeds its own app-server +/// instance and communicates over in-memory channels. +fn in_process_start_args( + config: &Config, + thread_manager: Arc, + arg0_paths: codex_arg0::Arg0DispatchPaths, + cli_overrides: Vec<(String, TomlValue)>, + cloud_requirements: CloudRequirementsLoader, +) -> InProcessClientStartArgs { + let config_warnings: Vec = config + .startup_warnings + .iter() + .map(|warning| ConfigWarningNotification { + summary: warning.clone(), + details: None, + path: None, + range: None, + }) + .collect(); - let app_event_tx_clone = app_event_tx; - tokio::spawn(async move { - let NewThread { - thread, - session_configured, - .. - } = match server.start_thread(config).await { - Ok(v) => v, - Err(err) => { - let message = format!("Failed to initialize codex: {err}"); - tracing::error!("{message}"); - app_event_tx_clone.send(AppEvent::CodexEvent(Event { - id: "".to_string(), - msg: EventMsg::Error(err.to_error_event(None)), - })); - app_event_tx_clone.send(AppEvent::FatalExitRequest(message)); - tracing::error!("failed to initialize codex: {err}"); - return; - } - }; - initialize_app_server_client_name(thread.as_ref()).await; + InProcessClientStartArgs { + arg0_paths, + config: Arc::new(config.clone()), + thread_manager: Some(thread_manager), + cli_overrides, + loader_overrides: LoaderOverrides::default(), + cloud_requirements, + feedback: CodexFeedback::new(), + config_warnings, + session_source: SessionSource::Cli, + enable_codex_api_key_env: false, + client_name: TUI_NOTIFY_CLIENT.to_string(), + client_version: CODEX_CLI_VERSION.to_string(), + experimental_api: true, + opt_out_notification_methods: in_process_typed_event_legacy_opt_outs(), + channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, + } +} - // Forward the captured `SessionConfigured` event so it can be rendered in the UI. - let ev = codex_protocol::protocol::Event { - // The `id` does not matter for rendering, so we can use a fake value. - id: "".to_string(), - msg: codex_protocol::protocol::EventMsg::SessionConfigured(session_configured), - }; - app_event_tx_clone.send(AppEvent::CodexEvent(ev)); +/// Monotonically increasing counter for JSON-RPC request IDs within a single +/// agent session. Each `InProcessAppServerClient` uses its own sequencer so IDs +/// are unique per session but not globally. +struct RequestIdSequencer { + next: i64, +} - let thread_clone = thread.clone(); - tokio::spawn(async move { - while let Some(op) = codex_op_rx.recv().await { - let id = thread_clone.submit(op).await; - if let Err(e) = id { - tracing::error!("failed to submit op: {e}"); +impl RequestIdSequencer { + fn new() -> Self { + Self { next: 1 } + } + + fn next(&mut self) -> RequestId { + let id = self.next; + self.next += 1; + RequestId::Integer(id) + } +} + +/// Tracks an outstanding exec-approval server request so the agent can resolve +/// it when the user decides. V1 and V2 correspond to the legacy and current +/// app-server request schemas; the response format differs between them. +#[derive(Debug, Clone, PartialEq, Eq)] +enum PendingExecApprovalRequest { + V1(RequestId), + V2(RequestId), +} + +/// Same as [`PendingExecApprovalRequest`] but for file-change (patch) approvals. +#[derive(Debug, Clone, PartialEq, Eq)] +enum PendingPatchApprovalRequest { + V1(RequestId), + V2(RequestId), +} + +/// Bookkeeping for server requests that are awaiting a user response. +/// +/// When the app-server sends a `ServerRequest` (e.g. an exec approval prompt), +/// the agent records the request ID here. When the TUI user makes a decision +/// and the corresponding `Op` arrives, the agent looks up the request ID and +/// calls `resolve_server_request` / `reject_server_request` to unblock the +/// app-server. +/// +/// All fields except `mcp_elicitations` are turn-scoped and cleared on turn +/// completion or abort via [`clear_turn_scoped`](Self::clear_turn_scoped). +#[derive(Default)] +/// Bookkeeping for outstanding interactive server requests awaiting user response. +/// +/// The app-server sends typed `ServerRequest` variants when it needs a human +/// decision (tool approval, patch review, elicitation, etc.). Each request is +/// registered here with a `RequestId` so that when the user responds (via +/// `Op`), the agent can look up the correct request and send the resolution +/// back to the app-server. +/// +/// Keys are `(thread_id, item_id)` tuples so requests from different threads +/// never collide. Turn-scoped entries are bulk-cleared on `TurnComplete` / +/// `TurnAborted` via [`clear_turn_scoped`](Self::clear_turn_scoped). +/// +/// `request_user_input` uses a `VecDeque` per `(thread_id, +/// turn_id)` because multiple user-input prompts can be queued for the same +/// turn and must be answered in FIFO order. +struct PendingServerRequests { + exec_approvals: HashMap<(String, String), PendingExecApprovalRequest>, + patch_approvals: HashMap<(String, String), PendingPatchApprovalRequest>, + mcp_elicitations: HashMap, + request_permissions: HashMap<(String, String), RequestId>, + request_user_input: HashMap<(String, String), VecDeque>, + dynamic_tool_calls: HashMap<(String, String), RequestId>, + /// File changes accumulated from `ItemStarted` notifications, keyed by + /// `(thread_id, item_id)`. Retrieved when the corresponding patch-approval + /// `ServerRequest` arrives and needs file-change context for the UI. + pending_file_changes: HashMap<(String, String), HashMap>, +} + +struct PendingMcpElicitationRequest { + thread_id: String, + server_name: String, + request_id: codex_protocol::mcp::RequestId, +} + +/// An `Op` tagged with the thread it should be routed to. +/// +/// The `ChatWidget` sends `ThreadScopedOp` values through the +/// `thread_scoped_op_tx` channel when an operation targets a thread other +/// than the one the widget is currently displaying (e.g. approving a request +/// from an inactive background thread). The agent loop receives these and +/// routes them to the correct in-process session or direct `CodexThread`. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct ThreadScopedOp { + pub(crate) thread_id: ThreadId, + pub(crate) op: Op, + /// The active turn ID at the time the op was created, used for interrupt + /// requests that need to identify which turn to cancel. + pub(crate) interrupt_turn_id: Option, +} + +impl PendingServerRequests { + fn clear_turn_scoped(&mut self, thread_id: &str) { + self.exec_approvals + .retain(|(pending_thread_id, _), _| pending_thread_id != thread_id); + self.patch_approvals + .retain(|(pending_thread_id, _), _| pending_thread_id != thread_id); + // MCP elicitation requests can outlive turn boundaries (turn_id is best-effort), + // so clear them only via resolve path or serverRequest/resolved notifications. + self.request_permissions + .retain(|(pending_thread_id, _), _| pending_thread_id != thread_id); + self.request_user_input + .retain(|(pending_thread_id, _), _| pending_thread_id != thread_id); + self.dynamic_tool_calls + .retain(|(pending_thread_id, _), _| pending_thread_id != thread_id); + self.pending_file_changes + .retain(|(pending_thread_id, _), _| pending_thread_id != thread_id); + } + + fn register_request_user_input( + &mut self, + thread_id: String, + turn_id: String, + request_id: RequestId, + ) { + self.request_user_input + .entry((thread_id, turn_id)) + .or_default() + .push_back(request_id); + } + + fn note_file_changes( + &mut self, + thread_id: String, + item_id: String, + changes: HashMap, + ) { + self.pending_file_changes + .insert((thread_id, item_id), changes); + } + + fn take_file_changes( + &mut self, + thread_id: &str, + item_id: &str, + ) -> HashMap { + self.pending_file_changes + .remove(&(thread_id.to_string(), item_id.to_string())) + .unwrap_or_default() + } + + fn pop_request_user_input_request_id( + &mut self, + thread_id: &str, + turn_id: &str, + ) -> Option { + let key = (thread_id.to_string(), turn_id.to_string()); + let request_id = self + .request_user_input + .get_mut(&key) + .and_then(VecDeque::pop_front); + if self + .request_user_input + .get(&key) + .is_some_and(VecDeque::is_empty) + { + self.request_user_input.remove(&key); + } + request_id + } + + fn register_mcp_elicitation( + &mut self, + thread_id: String, + pending_request_id: RequestId, + server_name: String, + request_id: codex_protocol::mcp::RequestId, + ) { + self.mcp_elicitations.insert( + pending_request_id, + PendingMcpElicitationRequest { + thread_id, + server_name, + request_id, + }, + ); + } + + fn pop_mcp_elicitation_request_id( + &mut self, + thread_id: &str, + server_name: &str, + request_id: &codex_protocol::mcp::RequestId, + ) -> Option { + let pending_request_id = self.mcp_elicitations.iter().find_map( + |(pending_request_id, pending_elicitation)| { + if pending_elicitation.thread_id == thread_id + && pending_elicitation.server_name == server_name + && pending_elicitation.request_id == *request_id + { + Some(pending_request_id.clone()) + } else { + None } - } - }); + }, + )?; + self.mcp_elicitations.remove(&pending_request_id); + Some(pending_request_id) + } - while let Ok(event) = thread.next_event().await { - let is_shutdown_complete = matches!(event.msg, EventMsg::ShutdownComplete); - app_event_tx_clone.send(AppEvent::CodexEvent(event)); - if is_shutdown_complete { - // ShutdownComplete is terminal for a thread; drop this receiver task so - // the Arc can be released and thread resources can clean up. - break; - } + fn clear_mcp_elicitation_by_request_id(&mut self, request_id: &RequestId) { + self.mcp_elicitations.remove(request_id); + } + + fn clear_resolved_request_id(&mut self, thread_id: &str, request_id: &RequestId) { + self.exec_approvals + .retain(|(pending_thread_id, _), pending| { + pending_thread_id != thread_id || pending.request_id() != request_id + }); + self.patch_approvals + .retain(|(pending_thread_id, _), pending| { + pending_thread_id != thread_id || pending.request_id() != request_id + }); + self.request_permissions + .retain(|(pending_thread_id, _), pending_request_id| { + pending_thread_id != thread_id || pending_request_id != request_id + }); + self.dynamic_tool_calls + .retain(|(pending_thread_id, _), pending_request_id| { + pending_thread_id != thread_id || pending_request_id != request_id + }); + self.request_user_input + .retain(|(pending_thread_id, _), pending_request_ids| { + if pending_thread_id != thread_id { + return true; + } + pending_request_ids.retain(|pending_request_id| pending_request_id != request_id); + !pending_request_ids.is_empty() + }); + let remaining_patch_items = self.patch_approvals.keys().cloned().collect::>(); + self.pending_file_changes + .retain(|key, _| key.0 != thread_id || remaining_patch_items.contains(key)); + self.clear_mcp_elicitation_by_request_id(request_id); + } +} + +impl PendingExecApprovalRequest { + fn request_id(&self) -> &RequestId { + match self { + Self::V1(request_id) | Self::V2(request_id) => request_id, } - }); + } +} - codex_op_tx +impl PendingPatchApprovalRequest { + fn request_id(&self) -> &RequestId { + match self { + Self::V1(request_id) | Self::V2(request_id) => request_id, + } + } } -/// Spawn agent loops for an existing thread (e.g., a forked thread). -/// Sends the provided `SessionConfiguredEvent` immediately, then forwards subsequent -/// events and accepts Ops for submission. -pub(crate) fn spawn_agent_from_existing( - thread: std::sync::Arc, - session_configured: codex_protocol::protocol::SessionConfiguredEvent, - app_event_tx: AppEventSender, -) -> UnboundedSender { - let (codex_op_tx, mut codex_op_rx) = unbounded_channel::(); +fn note_primary_legacy_event( + session_id: ThreadId, + conversation_id: Option, + event: &Event, + current_turn_ids: &mut HashMap, + pending_server_requests: &mut PendingServerRequests, +) -> bool { + let event_thread_id = conversation_id.unwrap_or(session_id); + let event_thread_id_string = event_thread_id.to_string(); - let app_event_tx_clone = app_event_tx; - tokio::spawn(async move { - initialize_app_server_client_name(thread.as_ref()).await; + match &event.msg { + EventMsg::TurnStarted(payload) => { + current_turn_ids.insert(event_thread_id_string.clone(), payload.turn_id.clone()); + } + EventMsg::TurnComplete(_) | EventMsg::TurnAborted(_) => { + current_turn_ids.remove(&event_thread_id_string); + pending_server_requests.clear_turn_scoped(&event_thread_id_string); + } + _ => {} + } - // Forward the captured `SessionConfigured` event so it can be rendered in the UI. - let ev = codex_protocol::protocol::Event { - id: "".to_string(), - msg: codex_protocol::protocol::EventMsg::SessionConfigured(session_configured), - }; - app_event_tx_clone.send(AppEvent::CodexEvent(ev)); + event_thread_id == session_id && matches!(event.msg, EventMsg::ShutdownComplete) +} - let thread_clone = thread.clone(); +async fn finalize_in_process_shutdown( + shutdown: Shutdown, + app_event_tx: AppEventSender, + pending_shutdown_complete: bool, +) where + Shutdown: Future> + Send + 'static, +{ + if pending_shutdown_complete { + let shutdown_app_event_tx = app_event_tx.clone(); tokio::spawn(async move { - while let Some(op) = codex_op_rx.recv().await { - let id = thread_clone.submit(op).await; - if let Err(e) = id { - tracing::error!("failed to submit op: {e}"); - } + if let Err(err) = shutdown.await { + send_warning_event( + &shutdown_app_event_tx, + format!("in-process app-server shutdown failed: {err}"), + ); } }); + send_codex_event(&app_event_tx, EventMsg::ShutdownComplete); + return; + } - while let Ok(event) = thread.next_event().await { - let is_shutdown_complete = matches!(event.msg, EventMsg::ShutdownComplete); - app_event_tx_clone.send(AppEvent::CodexEvent(event)); - if is_shutdown_complete { - // ShutdownComplete is terminal for a thread; drop this receiver task so - // the Arc can be released and thread resources can clean up. - break; - } + if let Err(err) = shutdown.await { + send_warning_event( + &app_event_tx, + format!("in-process app-server shutdown failed: {err}"), + ); + } +} + +fn command_text_to_tokens(command: Option) -> Vec { + command + .as_deref() + .map(|text| { + shlex::split(text) + .filter(|parts| !parts.is_empty()) + .unwrap_or_else(|| vec![text.to_string()]) + }) + .unwrap_or_default() +} + +fn command_actions_to_core( + command_actions: Option>, + command: Option<&str>, +) -> Vec { + match command_actions { + Some(actions) if !actions.is_empty() => actions + .into_iter() + .map(codex_app_server_protocol::CommandAction::into_core) + .collect(), + _ => command + .map(|cmd| { + vec![ParsedCommand::Unknown { + cmd: cmd.to_string(), + }] + }) + .unwrap_or_default(), + } +} + +fn network_approval_context_to_core( + value: codex_app_server_protocol::NetworkApprovalContext, +) -> codex_protocol::protocol::NetworkApprovalContext { + codex_protocol::protocol::NetworkApprovalContext { + host: value.host, + protocol: value.protocol.to_core(), + } +} + +fn additional_permission_profile_to_core( + value: codex_app_server_protocol::AdditionalPermissionProfile, +) -> PermissionProfile { + PermissionProfile { + network: value.network.map(|network| NetworkPermissions { + enabled: network.enabled, + }), + file_system: value.file_system.map(|file_system| FileSystemPermissions { + read: file_system.read, + write: file_system.write, + }), + macos: value.macos.map(|macos| MacOsSeatbeltProfileExtensions { + macos_preferences: macos.preferences, + macos_automation: macos.automations, + macos_launch_services: macos.launch_services, + macos_accessibility: macos.accessibility, + macos_calendar: macos.calendar, + macos_reminders: macos.reminders, + macos_contacts: macos.contacts, + }), + } +} + +fn granted_permission_profile_from_core(value: PermissionProfile) -> GrantedPermissionProfile { + let network = value.network.and_then(|network| { + if network.enabled.unwrap_or(false) { + Some(codex_app_server_protocol::AdditionalNetworkPermissions { + enabled: Some(true), + }) + } else { + None + } + }); + let file_system = value.file_system.and_then(|file_system| { + if file_system.is_empty() { + None + } else { + Some(codex_app_server_protocol::AdditionalFileSystemPermissions { + read: file_system.read, + write: file_system.write, + }) + } + }); + let macos = value.macos.and_then(|macos| { + let preferences = match macos.macos_preferences { + codex_protocol::models::MacOsPreferencesPermission::None => None, + preferences => Some(preferences), + }; + let automations = match macos.macos_automation { + codex_protocol::models::MacOsAutomationPermission::None => None, + automations => Some(automations), + }; + let launch_services = macos.macos_launch_services.then_some(true); + let accessibility = macos.macos_accessibility.then_some(true); + let calendar = macos.macos_calendar.then_some(true); + let reminders = macos.macos_reminders.then_some(true); + let contacts = match macos.macos_contacts { + codex_protocol::models::MacOsContactsPermission::None => None, + contacts => Some(contacts), + }; + if preferences.is_none() + && automations.is_none() + && launch_services.is_none() + && accessibility.is_none() + && calendar.is_none() + && reminders.is_none() + && contacts.is_none() + { + None + } else { + Some(GrantedMacOsPermissions { + preferences, + automations, + launch_services, + accessibility, + calendar, + reminders, + contacts, + }) } }); - codex_op_tx + GrantedPermissionProfile { + network, + file_system, + macos, + } } -/// Spawn an op-forwarding loop for an existing thread without subscribing to events. -pub(crate) fn spawn_op_forwarder(thread: std::sync::Arc) -> UnboundedSender { - let (codex_op_tx, mut codex_op_rx) = unbounded_channel::(); +fn command_execution_available_decisions_to_core( + value: Option>, +) -> Option> { + value.map(|decisions| { + decisions + .into_iter() + .map(|decision| match decision { + CommandExecutionApprovalDecision::Accept => { + codex_protocol::protocol::ReviewDecision::Approved + } + CommandExecutionApprovalDecision::AcceptForSession => { + codex_protocol::protocol::ReviewDecision::ApprovedForSession + } + CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment { + execpolicy_amendment, + } => codex_protocol::protocol::ReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment: execpolicy_amendment.into_core(), + }, + CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment { + network_policy_amendment, + } => codex_protocol::protocol::ReviewDecision::NetworkPolicyAmendment { + network_policy_amendment: network_policy_amendment.into_core(), + }, + CommandExecutionApprovalDecision::Decline => { + codex_protocol::protocol::ReviewDecision::Denied + } + CommandExecutionApprovalDecision::Cancel => { + codex_protocol::protocol::ReviewDecision::Abort + } + }) + .collect() + }) +} - tokio::spawn(async move { - initialize_app_server_client_name(thread.as_ref()).await; - while let Some(op) = codex_op_rx.recv().await { - if let Err(e) = thread.submit(op).await { - tracing::error!("failed to submit op: {e}"); +fn file_update_changes_to_core( + changes: Vec, +) -> HashMap { + changes + .into_iter() + .map(|change| { + let file_change = match change.kind { + PatchChangeKind::Add => FileChange::Add { + content: change.diff, + }, + PatchChangeKind::Delete => FileChange::Delete { + content: change.diff, + }, + PatchChangeKind::Update { move_path } => FileChange::Update { + unified_diff: change.diff, + move_path, + }, + }; + (PathBuf::from(change.path), file_change) + }) + .collect() +} + +fn file_change_approval_decision_from_review( + decision: codex_protocol::protocol::ReviewDecision, +) -> (FileChangeApprovalDecision, bool) { + match decision { + codex_protocol::protocol::ReviewDecision::Approved => { + (FileChangeApprovalDecision::Accept, false) + } + codex_protocol::protocol::ReviewDecision::ApprovedForSession => { + (FileChangeApprovalDecision::AcceptForSession, false) + } + codex_protocol::protocol::ReviewDecision::Denied => { + (FileChangeApprovalDecision::Decline, false) + } + codex_protocol::protocol::ReviewDecision::Abort => { + (FileChangeApprovalDecision::Cancel, false) + } + codex_protocol::protocol::ReviewDecision::ApprovedExecpolicyAmendment { .. } + | codex_protocol::protocol::ReviewDecision::NetworkPolicyAmendment { .. } => { + (FileChangeApprovalDecision::Accept, true) + } + } +} + +fn request_user_input_questions_to_core( + questions: Vec, +) -> Vec { + questions + .into_iter() + .map( + |question| codex_protocol::request_user_input::RequestUserInputQuestion { + id: question.id, + header: question.header, + question: question.question, + is_other: question.is_other, + is_secret: question.is_secret, + options: question.options.map(|options| { + options + .into_iter() + .map(|option| { + codex_protocol::request_user_input::RequestUserInputQuestionOption { + label: option.label, + description: option.description, + } + }) + .collect() + }), + }, + ) + .collect() +} + +fn skill_scope_to_core( + scope: codex_app_server_protocol::SkillScope, +) -> codex_protocol::protocol::SkillScope { + match scope { + codex_app_server_protocol::SkillScope::User => codex_protocol::protocol::SkillScope::User, + codex_app_server_protocol::SkillScope::Repo => codex_protocol::protocol::SkillScope::Repo, + codex_app_server_protocol::SkillScope::System => { + codex_protocol::protocol::SkillScope::System + } + codex_app_server_protocol::SkillScope::Admin => codex_protocol::protocol::SkillScope::Admin, + } +} + +fn skill_interface_to_core( + interface: codex_app_server_protocol::SkillInterface, +) -> codex_protocol::protocol::SkillInterface { + codex_protocol::protocol::SkillInterface { + display_name: interface.display_name, + short_description: interface.short_description, + icon_small: interface.icon_small, + icon_large: interface.icon_large, + brand_color: interface.brand_color, + default_prompt: interface.default_prompt, + } +} + +fn skill_dependencies_to_core( + dependencies: codex_app_server_protocol::SkillDependencies, +) -> codex_protocol::protocol::SkillDependencies { + codex_protocol::protocol::SkillDependencies { + tools: dependencies + .tools + .into_iter() + .map(|tool| codex_protocol::protocol::SkillToolDependency { + r#type: tool.r#type, + value: tool.value, + description: tool.description, + transport: tool.transport, + command: tool.command, + url: tool.url, + }) + .collect(), + } +} + +fn skill_metadata_to_core( + metadata: codex_app_server_protocol::SkillMetadata, +) -> codex_protocol::protocol::SkillMetadata { + codex_protocol::protocol::SkillMetadata { + name: metadata.name, + description: metadata.description, + short_description: metadata.short_description, + interface: metadata.interface.map(skill_interface_to_core), + dependencies: metadata.dependencies.map(skill_dependencies_to_core), + path: metadata.path, + scope: skill_scope_to_core(metadata.scope), + enabled: metadata.enabled, + } +} + +fn skills_list_entry_to_core( + entry: codex_app_server_protocol::SkillsListEntry, +) -> codex_protocol::protocol::SkillsListEntry { + codex_protocol::protocol::SkillsListEntry { + cwd: entry.cwd, + skills: entry + .skills + .into_iter() + .map(skill_metadata_to_core) + .collect(), + errors: entry + .errors + .into_iter() + .map(|error| codex_protocol::protocol::SkillErrorInfo { + path: error.path, + message: error.message, + }) + .collect(), + } +} + +fn remote_skill_summary_to_core( + summary: codex_app_server_protocol::RemoteSkillSummary, +) -> codex_protocol::protocol::RemoteSkillSummary { + codex_protocol::protocol::RemoteSkillSummary { + id: summary.id, + name: summary.name, + description: summary.description, + } +} + +fn remote_scope_to_protocol( + scope: codex_protocol::protocol::RemoteSkillHazelnutScope, +) -> codex_app_server_protocol::HazelnutScope { + match scope { + codex_protocol::protocol::RemoteSkillHazelnutScope::WorkspaceShared => { + codex_app_server_protocol::HazelnutScope::WorkspaceShared + } + codex_protocol::protocol::RemoteSkillHazelnutScope::AllShared => { + codex_app_server_protocol::HazelnutScope::AllShared + } + codex_protocol::protocol::RemoteSkillHazelnutScope::Personal => { + codex_app_server_protocol::HazelnutScope::Personal + } + codex_protocol::protocol::RemoteSkillHazelnutScope::Example => { + codex_app_server_protocol::HazelnutScope::Example + } + } +} + +fn product_surface_to_protocol( + product_surface: codex_protocol::protocol::RemoteSkillProductSurface, +) -> codex_app_server_protocol::ProductSurface { + match product_surface { + codex_protocol::protocol::RemoteSkillProductSurface::Chatgpt => { + codex_app_server_protocol::ProductSurface::Chatgpt + } + codex_protocol::protocol::RemoteSkillProductSurface::Codex => { + codex_app_server_protocol::ProductSurface::Codex + } + codex_protocol::protocol::RemoteSkillProductSurface::Api => { + codex_app_server_protocol::ProductSurface::Api + } + codex_protocol::protocol::RemoteSkillProductSurface::Atlas => { + codex_app_server_protocol::ProductSurface::Atlas + } + } +} + +async fn resolve_server_request( + client: &InProcessAppServerClient, + request_id: RequestId, + value: serde_json::Value, + method: &str, + app_event_tx: &AppEventSender, +) { + if let Err(err) = client.resolve_server_request(request_id, value).await { + send_error_event( + app_event_tx, + format!("failed to resolve server request for `{method}`: {err}"), + ); + } +} + +async fn reject_server_request( + client: &InProcessAppServerClient, + request_id: RequestId, + method: &str, + reason: String, + app_event_tx: &AppEventSender, +) { + if let Err(err) = client + .reject_server_request( + request_id, + JSONRPCErrorError { + code: -32000, + message: reason, + data: None, + }, + ) + .await + { + send_error_event( + app_event_tx, + format!("failed to reject `{method}` server request: {err}"), + ); + } +} + +async fn forward_op_to_thread( + thread_manager: &ThreadManager, + thread_id: &str, + op: Op, + app_event_tx: &AppEventSender, +) { + let op_type = serde_json::to_value(&op) + .ok() + .and_then(|value| { + value + .get("type") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + }) + .unwrap_or_else(|| "unknown".to_string()); + let thread_id = match ThreadId::from_string(thread_id) { + Ok(thread_id) => thread_id, + Err(err) => { + send_error_event( + app_event_tx, + format!("failed to parse in-process thread id `{thread_id}`: {err}"), + ); + return; + } + }; + + if let Err(err) = thread_manager.send_op(thread_id, op).await { + send_error_event( + app_event_tx, + format!("failed to forward `{op_type}` to in-process thread: {err}"), + ); + } +} + +fn local_only_deferred_message(action_name: &str) -> String { + format!("{action_name} is temporarily unavailable in in-process local-only mode") +} + +fn app_server_request_id_to_mcp(request_id: RequestId) -> codex_protocol::mcp::RequestId { + // In this path the app-server request id is used as the TUI correlation id. + // App-server translates the resolved server request back to the original MCP request. + match request_id { + RequestId::String(id) => codex_protocol::mcp::RequestId::String(id), + RequestId::Integer(id) => codex_protocol::mcp::RequestId::Integer(id), + } +} + +fn mcp_elicitation_request_to_core( + request: McpServerElicitationRequest, +) -> codex_protocol::approvals::ElicitationRequest { + match request { + McpServerElicitationRequest::Form { + meta, + message, + requested_schema, + } => codex_protocol::approvals::ElicitationRequest::Form { + meta, + message, + requested_schema: serde_json::to_value(requested_schema).unwrap_or_else(|err| { + warn!("failed to serialize MCP elicitation schema for local adapter: {err}"); + serde_json::Value::Null + }), + }, + McpServerElicitationRequest::Url { + meta, + message, + url, + elicitation_id, + } => codex_protocol::approvals::ElicitationRequest::Url { + meta, + message, + url, + elicitation_id, + }, + } +} + +fn mcp_elicitation_action_to_protocol( + action: codex_protocol::approvals::ElicitationAction, +) -> McpServerElicitationAction { + match action { + codex_protocol::approvals::ElicitationAction::Accept => McpServerElicitationAction::Accept, + codex_protocol::approvals::ElicitationAction::Decline => { + McpServerElicitationAction::Decline + } + codex_protocol::approvals::ElicitationAction::Cancel => McpServerElicitationAction::Cancel, + } +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +struct StoredHistoryEntry { + session_id: String, + ts: u64, + text: String, +} + +fn history_file_path(config: &Config) -> PathBuf { + config.codex_home.join(HISTORY_FILENAME) +} + +fn now_unix_seconds() -> Result { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .map_err(|err| format!("system clock before unix epoch: {err}")) +} + +fn history_entry_from_line(line: &str) -> Option { + if let Ok(entry) = serde_json::from_str::(line) { + return Some(codex_protocol::message_history::HistoryEntry { + conversation_id: entry.session_id, + ts: entry.ts, + text: entry.text, + }); + } + + serde_json::from_str::(line).ok() +} + +#[cfg(unix)] +fn history_log_id(metadata: &std::fs::Metadata) -> u64 { + use std::os::unix::fs::MetadataExt; + metadata.ino() +} + +#[cfg(windows)] +fn history_log_id(metadata: &std::fs::Metadata) -> u64 { + use std::os::windows::fs::MetadataExt; + metadata.creation_time() +} + +#[cfg(not(any(unix, windows)))] +fn history_log_id(_metadata: &std::fs::Metadata) -> u64 { + 0 +} + +fn trim_target_bytes(max_bytes: u64, newest_entry_len: u64) -> u64 { + let soft_cap_bytes = ((max_bytes as f64) * HISTORY_SOFT_CAP_RATIO) + .floor() + .clamp(1.0, max_bytes as f64) as u64; + soft_cap_bytes.max(newest_entry_len) +} + +fn trim_history_file(file: &mut std::fs::File, max_bytes: Option) -> Result<(), String> { + let Some(max_bytes) = max_bytes else { + return Ok(()); + }; + if max_bytes == 0 { + return Ok(()); + } + + let max_bytes = u64::try_from(max_bytes) + .map_err(|err| format!("invalid history max_bytes value: {err}"))?; + let mut current_len = file + .metadata() + .map_err(|err| format!("failed to read history metadata: {err}"))? + .len(); + if current_len <= max_bytes { + return Ok(()); + } + + let mut reader_file = file + .try_clone() + .map_err(|err| format!("failed to clone history file: {err}"))?; + reader_file + .seek(SeekFrom::Start(0)) + .map_err(|err| format!("failed to seek history file: {err}"))?; + let mut buf_reader = BufReader::new(reader_file); + let mut line_buf = String::new(); + let mut line_lengths = Vec::new(); + loop { + line_buf.clear(); + let bytes = buf_reader + .read_line(&mut line_buf) + .map_err(|err| format!("failed to read history line: {err}"))?; + if bytes == 0 { + break; + } + line_lengths.push(bytes as u64); + } + if line_lengths.is_empty() { + return Ok(()); + } + + let last_index = line_lengths.len() - 1; + let trim_target = trim_target_bytes(max_bytes, line_lengths[last_index]); + let mut drop_bytes = 0u64; + let mut idx = 0usize; + while current_len > trim_target && idx < last_index { + current_len = current_len.saturating_sub(line_lengths[idx]); + drop_bytes += line_lengths[idx]; + idx += 1; + } + if drop_bytes == 0 { + return Ok(()); + } + + let mut reader = buf_reader.into_inner(); + reader + .seek(SeekFrom::Start(drop_bytes)) + .map_err(|err| format!("failed to seek trimmed history position: {err}"))?; + let capacity = usize::try_from(current_len).unwrap_or(0); + let mut tail = Vec::with_capacity(capacity); + reader + .read_to_end(&mut tail) + .map_err(|err| format!("failed to read history tail: {err}"))?; + + file.set_len(0) + .map_err(|err| format!("failed to truncate history file: {err}"))?; + file.seek(SeekFrom::Start(0)) + .map_err(|err| format!("failed to seek truncated history file: {err}"))?; + file.write_all(&tail) + .map_err(|err| format!("failed to write trimmed history file: {err}"))?; + file.flush() + .map_err(|err| format!("failed to flush trimmed history file: {err}"))?; + Ok(()) +} + +fn append_history_entry_blocking( + path: PathBuf, + line: String, + max_bytes: Option, +) -> Result<(), String> { + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.append(true); + options.mode(0o600); + } + let mut file = options + .open(path) + .map_err(|err| format!("failed to open history file: {err}"))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let metadata = file + .metadata() + .map_err(|err| format!("failed to stat history file: {err}"))?; + let current_mode = metadata.permissions().mode() & 0o777; + if current_mode != 0o600 { + let mut permissions = metadata.permissions(); + permissions.set_mode(0o600); + file.set_permissions(permissions) + .map_err(|err| format!("failed to set history permissions: {err}"))?; + } + } + + for _ in 0..HISTORY_LOCK_MAX_RETRIES { + match file.try_lock() { + Ok(()) => { + file.seek(SeekFrom::End(0)) + .map_err(|err| format!("failed to seek history file: {err}"))?; + file.write_all(line.as_bytes()) + .map_err(|err| format!("failed to append history entry: {err}"))?; + file.flush() + .map_err(|err| format!("failed to flush history entry: {err}"))?; + trim_history_file(&mut file, max_bytes)?; + return Ok(()); + } + Err(std::fs::TryLockError::WouldBlock) => { + std::thread::sleep(HISTORY_LOCK_RETRY_SLEEP); + } + Err(err) => { + return Err(format!("failed to acquire exclusive history lock: {err}")); } } - }); + } - codex_op_tx + Err("could not acquire exclusive history lock after retries".to_string()) +} + +fn read_history_entry_blocking( + path: PathBuf, + requested_log_id: u64, + offset: usize, +) -> Result, String> { + // Open directly and treat NotFound as empty history (no TOCTOU pre-check). + let file = match OpenOptions::new().read(true).open(path) { + Ok(f) => f, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(format!("failed to open history file: {err}")), + }; + let metadata = file + .metadata() + .map_err(|err| format!("failed to stat history file: {err}"))?; + let current_log_id = history_log_id(&metadata); + if requested_log_id != 0 && requested_log_id != current_log_id { + return Ok(None); + } + + for _ in 0..HISTORY_LOCK_MAX_RETRIES { + match file.try_lock_shared() { + Ok(()) => { + let reader = BufReader::new(&file); + for (idx, line_result) in reader.lines().enumerate() { + let line = + line_result.map_err(|err| format!("failed to read history line: {err}"))?; + if idx == offset { + return Ok(history_entry_from_line(&line)); + } + } + return Ok(None); + } + Err(std::fs::TryLockError::WouldBlock) => { + std::thread::sleep(HISTORY_LOCK_RETRY_SLEEP); + } + Err(err) => { + return Err(format!("failed to acquire shared history lock: {err}")); + } + } + } + + Err("could not acquire shared history lock after retries".to_string()) +} + +async fn append_history_entry_local( + config: &Config, + session_id: &ThreadId, + text: String, +) -> Result<(), String> { + if config.history.persistence == HistoryPersistence::None { + return Ok(()); + } + + let path = history_file_path(config); + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|err| format!("failed to create history dir: {err}"))?; + } + + let entry = StoredHistoryEntry { + session_id: session_id.to_string(), + ts: now_unix_seconds()?, + text, + }; + let mut line = serde_json::to_string(&entry) + .map_err(|err| format!("failed to serialize history entry: {err}"))?; + line.push('\n'); + let max_bytes = config.history.max_bytes; + tokio::task::spawn_blocking(move || append_history_entry_blocking(path, line, max_bytes)) + .await + .map_err(|err| format!("failed to join history append task: {err}"))? +} + +async fn read_history_entry_local( + config: &Config, + requested_log_id: u64, + offset: usize, +) -> Result, String> { + let path = history_file_path(config); + tokio::task::spawn_blocking(move || read_history_entry_blocking(path, requested_log_id, offset)) + .await + .map_err(|err| format!("failed to join history read task: {err}"))? +} + +async fn local_external_chatgpt_tokens( + auth_manager: Arc, +) -> Result { + let auth = auth_manager + .auth_cached() + .ok_or_else(|| "no cached auth available for local token refresh".to_string())?; + if !auth.is_external_chatgpt_tokens() { + return Err("external ChatGPT token auth is not active".to_string()); + } + + let base_external_auth_refresher = auth_manager.replace_external_auth_refresher(None); + let refresh_result = match base_external_auth_refresher.clone() { + Some(refresher) => { + let _override_guard = auth_manager + .push_external_auth_override(refresher, auth_manager.forced_chatgpt_workspace_id()); + auth_manager.refresh_token_from_authority().await + } + None => Err(std::io::Error::other("external auth refresher is not configured").into()), + }; + let _ = auth_manager.replace_external_auth_refresher(base_external_auth_refresher); + refresh_result.map_err(|err| format!("failed to refresh external ChatGPT auth: {err}"))?; + + let auth = auth_manager + .auth_cached() + .ok_or_else(|| "no cached auth available after local token refresh".to_string())?; + if !auth.is_external_chatgpt_tokens() { + return Err("external ChatGPT token auth is not active after refresh".to_string()); + } + + let access_token = auth + .get_token() + .map_err(|err| format!("failed to read external access token: {err}"))?; + let chatgpt_account_id = auth + .get_account_id() + .ok_or_else(|| "external token auth is missing chatgpt account id".to_string())?; + let chatgpt_plan_type = auth.account_plan_type().map(|plan_type| match plan_type { + AccountPlanType::Free => "free".to_string(), + AccountPlanType::Go => "go".to_string(), + AccountPlanType::Plus => "plus".to_string(), + AccountPlanType::Pro => "pro".to_string(), + AccountPlanType::Team => "team".to_string(), + AccountPlanType::Business => "business".to_string(), + AccountPlanType::Enterprise => "enterprise".to_string(), + AccountPlanType::Edu => "edu".to_string(), + AccountPlanType::Unknown => "unknown".to_string(), + }); + + Ok(ChatgptAuthTokensRefreshResponse { + access_token, + chatgpt_account_id, + chatgpt_plan_type, + }) +} + +fn validate_refreshed_chatgpt_account( + previous_account_id: Option<&str>, + refreshed_account_id: &str, +) -> Result<(), String> { + if let Some(previous_account_id) = previous_account_id + && previous_account_id != refreshed_account_id + { + return Err(format!( + "local auth refresh account mismatch: expected `{previous_account_id}`, got `{refreshed_account_id}`" + )); + } + Ok(()) +} + +fn send_codex_event(app_event_tx: &AppEventSender, msg: EventMsg) { + app_event_tx.send(AppEvent::CodexEvent(Event { + id: String::new(), + msg, + })); +} + +fn send_routed_codex_event( + app_event_tx: &AppEventSender, + session_id: ThreadId, + event_thread_id: ThreadId, + msg: EventMsg, +) { + let event = Event { + id: String::new(), + msg, + }; + if event_thread_id == session_id { + app_event_tx.send(AppEvent::CodexEvent(event)); + } else { + app_event_tx.send(AppEvent::ThreadEvent { + thread_id: event_thread_id, + event, + }); + } +} + +fn send_warning_event(app_event_tx: &AppEventSender, message: String) { + send_codex_event(app_event_tx, EventMsg::Warning(WarningEvent { message })); +} + +fn send_error_event(app_event_tx: &AppEventSender, message: String) { + send_codex_event( + app_event_tx, + EventMsg::Error(codex_protocol::protocol::ErrorEvent { + message, + codex_error_info: None, + }), + ); +} + +fn lagged_event_warning_message(skipped: usize) -> String { + format!("in-process app-server event stream lagged; dropped {skipped} events") +} + +async fn send_request_with_response( + client: &InProcessAppServerClient, + request: ClientRequest, + method: &str, +) -> Result +where + T: serde::de::DeserializeOwned, +{ + client.request_typed(request).await.map_err(|err| { + if method.is_empty() { + err.to_string() + } else { + format!("{method}: {err}") + } + }) +} + +async fn local_history_metadata(config: &Config) -> (u64, usize) { + if config.history.persistence == HistoryPersistence::None { + return (0, 0); + } + + let path = history_file_path(config); + let log_id = match tokio::fs::metadata(&path).await { + Ok(metadata) => history_log_id(&metadata), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return (0, 0), + Err(_) => return (0, 0), + }; + let mut file = match tokio::fs::File::open(path).await { + Ok(file) => file, + Err(_) => return (log_id, 0), + }; + let mut buf = [0u8; 8192]; + let mut count = 0usize; + loop { + match file.read(&mut buf).await { + Ok(0) => break, + Ok(n) => { + count += buf[..n].iter().filter(|&&b| b == b'\n').count(); + } + Err(_) => return (log_id, 0), + } + } + + (log_id, count) +} + +async fn session_configured_from_thread_start_response( + config: &Config, + response: ThreadStartResponse, +) -> Result { + let session_id = ThreadId::from_string(&response.thread.id) + .map_err(|err| format!("thread/start returned invalid thread id: {err}"))?; + let (history_log_id, history_entry_count) = local_history_metadata(config).await; + + Ok(SessionConfiguredEvent { + session_id, + forked_from_id: None, + thread_name: response.thread.name, + model: response.model, + model_provider_id: response.model_provider, + service_tier: response.service_tier, + approval_policy: response.approval_policy.to_core(), + sandbox_policy: response.sandbox.to_core(), + cwd: response.cwd, + reasoning_effort: response.reasoning_effort, + history_log_id, + history_entry_count, + initial_messages: None, + network_proxy: None, + rollout_path: response.thread.path, + }) +} + +fn thread_sandbox_mode( + sandbox_policy: &codex_protocol::protocol::SandboxPolicy, +) -> codex_app_server_protocol::SandboxMode { + match sandbox_policy { + codex_protocol::protocol::SandboxPolicy::DangerFullAccess + | codex_protocol::protocol::SandboxPolicy::ExternalSandbox { .. } => { + codex_app_server_protocol::SandboxMode::DangerFullAccess + } + codex_protocol::protocol::SandboxPolicy::ReadOnly { .. } => { + codex_app_server_protocol::SandboxMode::ReadOnly + } + codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. } => { + codex_app_server_protocol::SandboxMode::WorkspaceWrite + } + } +} + +fn thread_start_params_from_config(config: &Config) -> ThreadStartParams { + ThreadStartParams { + model: config.model.clone(), + model_provider: Some(config.model_provider_id.clone()), + service_tier: Some(config.service_tier), + cwd: Some(config.cwd.to_string_lossy().into_owned()), + approval_policy: Some(config.permissions.approval_policy.value().into()), + sandbox: Some(thread_sandbox_mode(config.permissions.sandbox_policy.get())), + config: None, + service_name: None, + base_instructions: config.base_instructions.clone(), + developer_instructions: config.developer_instructions.clone(), + personality: config.personality, + ephemeral: None, + dynamic_tools: None, + mock_experimental_field: None, + experimental_raw_events: false, + persist_extended_history: false, + } +} + +/// Enriches an early synthetic `SessionConfigured` with later authoritative +/// data from the event stream. +/// +/// The TUI emits startup session state immediately so first paint does not wait +/// on the event stream. When app-server later sends a richer +/// `SessionConfigured` for the same session, this merges fields that were +/// unknown during bootstrap and suppresses no-op updates. +fn merge_session_configured_update( + current: &SessionConfiguredEvent, + update: SessionConfiguredEvent, +) -> Option { + if update.session_id != current.session_id { + return None; + } + + let merged = SessionConfiguredEvent { + session_id: update.session_id, + forked_from_id: update.forked_from_id.or(current.forked_from_id), + thread_name: update.thread_name.or_else(|| current.thread_name.clone()), + model: update.model, + model_provider_id: update.model_provider_id, + service_tier: update.service_tier, + approval_policy: update.approval_policy, + sandbox_policy: update.sandbox_policy, + cwd: update.cwd, + reasoning_effort: update.reasoning_effort, + history_log_id: update.history_log_id, + history_entry_count: update.history_entry_count, + initial_messages: update + .initial_messages + .or_else(|| current.initial_messages.clone()), + network_proxy: update + .network_proxy + .or_else(|| current.network_proxy.clone()), + rollout_path: update.rollout_path.or_else(|| current.rollout_path.clone()), + }; + + let changed = merged.forked_from_id != current.forked_from_id + || merged.thread_name != current.thread_name + || merged.model != current.model + || merged.model_provider_id != current.model_provider_id + || merged.service_tier != current.service_tier + || merged.approval_policy != current.approval_policy + || merged.sandbox_policy != current.sandbox_policy + || merged.cwd != current.cwd + || merged.reasoning_effort != current.reasoning_effort + || merged.history_log_id != current.history_log_id + || merged.history_entry_count != current.history_entry_count + || merged.initial_messages.is_some() != current.initial_messages.is_some() + || merged.network_proxy != current.network_proxy + || merged.rollout_path != current.rollout_path; + + changed.then_some(merged) +} + +fn active_turn_id_from_turns(turns: &[codex_app_server_protocol::Turn]) -> Option { + turns.iter().rev().find_map(|turn| { + if turn.status == TurnStatus::InProgress { + Some(turn.id.clone()) + } else { + None + } + }) +} + +fn server_request_method_name(request: &ServerRequest) -> String { + serde_json::to_value(request) + .ok() + .and_then(|value| { + value + .get("method") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + }) + .unwrap_or_else(|| "unknown".to_string()) +} + +fn normalize_legacy_notification_method(method: &str) -> &str { + method.strip_prefix("codex/event/").unwrap_or(method) +} + +struct DecodedLegacyNotification { + conversation_id: Option, + event: Event, +} + +fn decode_legacy_notification( + notification: JSONRPCNotification, +) -> Result { + let value = notification + .params + .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())); + let method = notification.method; + let normalized_method = normalize_legacy_notification_method(&method).to_string(); + let serde_json::Value::Object(mut object) = value else { + return Err(format!( + "legacy notification `{method}` params were not an object" + )); + }; + let conversation_id = object + .get("conversationId") + .and_then(serde_json::Value::as_str) + .map(ThreadId::from_string) + .transpose() + .map_err(|err| { + format!("legacy notification `{method}` has invalid conversationId: {err}") + })?; + let event_id = object + .get("id") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + .unwrap_or_default(); + let mut event_payload = if let Some(serde_json::Value::Object(msg_payload)) = object.get("msg") + { + serde_json::Value::Object(msg_payload.clone()) + } else { + object.remove("conversationId"); + serde_json::Value::Object(object) + }; + let serde_json::Value::Object(ref mut object) = event_payload else { + return Err(format!( + "legacy notification `{method}` event payload was not an object" + )); + }; + object.insert( + "type".to_string(), + serde_json::Value::String(normalized_method), + ); + + let msg: EventMsg = serde_json::from_value(event_payload) + .map_err(|err| format!("failed to decode event: {err}"))?; + Ok(DecodedLegacyNotification { + conversation_id, + event: Event { id: event_id, msg }, + }) +} + +#[cfg(test)] +fn legacy_notification_to_event(notification: JSONRPCNotification) -> Result { + decode_legacy_notification(notification).map(|decoded| decoded.event) +} + +/// Translate a single TUI `Op` into the corresponding app-server client +/// request. Returns `true` when the op was `Op::Shutdown`, signalling the +/// caller to exit the agent loop. +#[expect( + clippy::too_many_arguments, + reason = "migration routing keeps dependencies explicit" +)] +async fn process_in_process_command( + op: Op, + thread_id: &str, + primary_thread_id: &str, + interrupt_turn_id: Option<&str>, + session_id: &ThreadId, + config: &Config, + current_turn_ids: &mut HashMap, + request_ids: &mut RequestIdSequencer, + pending_server_requests: &mut PendingServerRequests, + client: &InProcessAppServerClient, + thread_manager: &ThreadManager, + app_event_tx: &AppEventSender, +) -> bool { + match op { + Op::Interrupt => { + let Some(turn_id) = interrupt_turn_id + .map(str::to_owned) + .or_else(|| current_turn_ids.get(thread_id).cloned()) + else { + send_warning_event( + app_event_tx, + "turn/interrupt skipped because there is no active turn".to_string(), + ); + return false; + }; + let request = ClientRequest::TurnInterrupt { + request_id: request_ids.next(), + params: TurnInterruptParams { + thread_id: thread_id.to_string(), + turn_id, + }, + }; + if let Err(err) = send_request_with_response::( + client, + request, + "turn/interrupt", + ) + .await + { + send_error_event(app_event_tx, err); + } + } + Op::Review { review_request } => { + let target = match review_request.target { + CoreReviewTarget::UncommittedChanges => ApiReviewTarget::UncommittedChanges, + CoreReviewTarget::BaseBranch { branch } => ApiReviewTarget::BaseBranch { branch }, + CoreReviewTarget::Commit { sha, title } => ApiReviewTarget::Commit { sha, title }, + CoreReviewTarget::Custom { instructions } => { + ApiReviewTarget::Custom { instructions } + } + }; + let request = ClientRequest::ReviewStart { + request_id: request_ids.next(), + params: ReviewStartParams { + thread_id: thread_id.to_string(), + target, + delivery: Some(ReviewDelivery::Inline), + }, + }; + match send_request_with_response::(client, request, "review/start") + .await + { + Ok(response) => { + current_turn_ids.insert(thread_id.to_string(), response.turn.id); + } + Err(err) => send_error_event(app_event_tx, err), + } + } + Op::UserInput { + items, + final_output_json_schema, + } => { + let request = ClientRequest::TurnStart { + request_id: request_ids.next(), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: items.into_iter().map(Into::into).collect(), + output_schema: final_output_json_schema, + ..TurnStartParams::default() + }, + }; + match send_request_with_response::(client, request, "turn/start") + .await + { + Ok(response) => { + current_turn_ids.insert(thread_id.to_string(), response.turn.id); + } + Err(err) => send_error_event(app_event_tx, err), + } + } + Op::UserTurn { + items, + cwd, + approval_policy, + sandbox_policy, + model, + effort, + summary, + service_tier, + final_output_json_schema, + collaboration_mode, + personality, + } => { + let request = ClientRequest::TurnStart { + request_id: request_ids.next(), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: items.into_iter().map(Into::into).collect(), + cwd: Some(cwd), + approval_policy: Some(approval_policy.into()), + sandbox_policy: Some(sandbox_policy.into()), + model: Some(model), + service_tier, + effort, + summary, + personality, + output_schema: final_output_json_schema, + collaboration_mode, + }, + }; + match send_request_with_response::(client, request, "turn/start") + .await + { + Ok(response) => { + current_turn_ids.insert(thread_id.to_string(), response.turn.id); + } + Err(err) => send_error_event(app_event_tx, err), + } + } + Op::RealtimeConversationStart(params) => { + let request = ClientRequest::ThreadRealtimeStart { + request_id: request_ids.next(), + params: codex_app_server_protocol::ThreadRealtimeStartParams { + thread_id: thread_id.to_string(), + prompt: params.prompt, + session_id: params.session_id, + }, + }; + if let Err(err) = send_request_with_response::( + client, + request, + "thread/realtime/start", + ) + .await + { + send_error_event(app_event_tx, err); + } + } + Op::RealtimeConversationAudio(params) => { + let request = ClientRequest::ThreadRealtimeAppendAudio { + request_id: request_ids.next(), + params: codex_app_server_protocol::ThreadRealtimeAppendAudioParams { + thread_id: thread_id.to_string(), + audio: params.frame.into(), + }, + }; + if let Err(err) = send_request_with_response::( + client, + request, + "thread/realtime/appendAudio", + ) + .await + { + send_error_event(app_event_tx, err); + } + } + Op::RealtimeConversationText(params) => { + let request = ClientRequest::ThreadRealtimeAppendText { + request_id: request_ids.next(), + params: codex_app_server_protocol::ThreadRealtimeAppendTextParams { + thread_id: thread_id.to_string(), + text: params.text, + }, + }; + if let Err(err) = send_request_with_response::( + client, + request, + "thread/realtime/appendText", + ) + .await + { + send_error_event(app_event_tx, err); + } + } + Op::RealtimeConversationClose => { + let request = ClientRequest::ThreadRealtimeStop { + request_id: request_ids.next(), + params: codex_app_server_protocol::ThreadRealtimeStopParams { + thread_id: thread_id.to_string(), + }, + }; + if let Err(err) = send_request_with_response::( + client, + request, + "thread/realtime/stop", + ) + .await + { + send_error_event(app_event_tx, err); + } + } + Op::CleanBackgroundTerminals => { + let request = ClientRequest::ThreadBackgroundTerminalsClean { + request_id: request_ids.next(), + params: codex_app_server_protocol::ThreadBackgroundTerminalsCleanParams { + thread_id: thread_id.to_string(), + }, + }; + if let Err(err) = send_request_with_response::( + client, + request, + "thread/backgroundTerminals/clean", + ) + .await + { + send_error_event(app_event_tx, err); + } + } + Op::ListModels => { + let request = ClientRequest::ModelList { + request_id: request_ids.next(), + params: ModelListParams::default(), + }; + if let Err(err) = + send_request_with_response::(client, request, "model/list").await + { + send_error_event(app_event_tx, err); + } + } + Op::RefreshMcpServers { config: _ } => { + let request = ClientRequest::McpServerRefresh { + request_id: request_ids.next(), + params: None, + }; + if let Err(err) = send_request_with_response::( + client, + request, + "config/mcpServer/reload", + ) + .await + { + send_error_event(app_event_tx, err); + } + } + Op::ListSkills { cwds, force_reload } => { + let request = ClientRequest::SkillsList { + request_id: request_ids.next(), + params: codex_app_server_protocol::SkillsListParams { + cwds, + force_reload, + per_cwd_extra_user_roots: None, + }, + }; + match send_request_with_response::(client, request, "skills/list") + .await + { + Ok(response) => { + send_codex_event( + app_event_tx, + EventMsg::ListSkillsResponse(ListSkillsResponseEvent { + skills: response + .data + .into_iter() + .map(skills_list_entry_to_core) + .collect(), + }), + ); + } + Err(err) => send_error_event(app_event_tx, err), + } + } + Op::ListRemoteSkills { + hazelnut_scope, + product_surface, + enabled, + } => { + let request = ClientRequest::SkillsRemoteList { + request_id: request_ids.next(), + params: codex_app_server_protocol::SkillsRemoteReadParams { + hazelnut_scope: remote_scope_to_protocol(hazelnut_scope), + product_surface: product_surface_to_protocol(product_surface), + enabled: enabled.unwrap_or(false), + }, + }; + match send_request_with_response::( + client, + request, + "skills/remote/list", + ) + .await + { + Ok(response) => { + send_codex_event( + app_event_tx, + EventMsg::ListRemoteSkillsResponse(ListRemoteSkillsResponseEvent { + skills: response + .data + .into_iter() + .map(remote_skill_summary_to_core) + .collect(), + }), + ); + } + Err(err) => send_error_event(app_event_tx, err), + } + } + Op::DownloadRemoteSkill { hazelnut_id } => { + let request = ClientRequest::SkillsRemoteExport { + request_id: request_ids.next(), + params: codex_app_server_protocol::SkillsRemoteWriteParams { hazelnut_id }, + }; + match send_request_with_response::( + client, + request, + "skills/remote/export", + ) + .await + { + Ok(response) => { + let id = response.id; + send_codex_event( + app_event_tx, + EventMsg::RemoteSkillDownloaded(RemoteSkillDownloadedEvent { + id: id.clone(), + name: id, + path: response.path, + }), + ); + } + Err(err) => send_error_event(app_event_tx, err), + } + } + Op::Compact => { + let request = ClientRequest::ThreadCompactStart { + request_id: request_ids.next(), + params: codex_app_server_protocol::ThreadCompactStartParams { + thread_id: thread_id.to_string(), + }, + }; + if let Err(err) = send_request_with_response::( + client, + request, + "thread/compact/start", + ) + .await + { + send_error_event(app_event_tx, err); + } + } + Op::ThreadRollback { num_turns } => { + let request = ClientRequest::ThreadRollback { + request_id: request_ids.next(), + params: codex_app_server_protocol::ThreadRollbackParams { + thread_id: thread_id.to_string(), + num_turns, + }, + }; + match send_request_with_response::( + client, + request, + "thread/rollback", + ) + .await + { + Ok(response) => { + if let Some(current_turn_id) = active_turn_id_from_turns(&response.thread.turns) + { + current_turn_ids.insert(thread_id.to_string(), current_turn_id); + } else { + current_turn_ids.remove(thread_id); + } + } + Err(err) => { + send_codex_event( + app_event_tx, + EventMsg::Error(codex_protocol::protocol::ErrorEvent { + message: err, + codex_error_info: Some(CodexErrorInfo::ThreadRollbackFailed), + }), + ); + } + } + } + Op::SetThreadName { name } => { + let request = ClientRequest::ThreadSetName { + request_id: request_ids.next(), + params: codex_app_server_protocol::ThreadSetNameParams { + thread_id: thread_id.to_string(), + name, + }, + }; + if let Err(err) = send_request_with_response::( + client, + request, + "thread/name/set", + ) + .await + { + send_error_event(app_event_tx, err); + } + } + Op::ExecApproval { id, decision, .. } => { + let Some(pending_request) = pending_server_requests + .exec_approvals + .remove(&(thread_id.to_string(), id.clone())) + else { + send_warning_event( + app_event_tx, + format!("exec approval ignored because request id `{id}` was not pending"), + ); + return false; + }; + + let (request_id, result) = match pending_request { + PendingExecApprovalRequest::V2(request_id) => { + let response = CommandExecutionRequestApprovalResponse { + decision: CommandExecutionApprovalDecision::from(decision), + }; + let result = match serde_json::to_value(response) { + Ok(value) => value, + Err(err) => { + send_error_event( + app_event_tx, + format!("failed to encode exec approval response: {err}"), + ); + return false; + } + }; + (request_id, result) + } + PendingExecApprovalRequest::V1(request_id) => { + let response = ExecCommandApprovalResponse { decision }; + let result = match serde_json::to_value(response) { + Ok(value) => value, + Err(err) => { + send_error_event( + app_event_tx, + format!("failed to encode legacy exec approval response: {err}"), + ); + return false; + } + }; + (request_id, result) + } + }; + + resolve_server_request( + client, + request_id, + result, + "item/commandExecution/requestApproval", + app_event_tx, + ) + .await; + } + Op::PatchApproval { id, decision } => { + let Some(pending_request) = pending_server_requests + .patch_approvals + .remove(&(thread_id.to_string(), id.clone())) + else { + send_warning_event( + app_event_tx, + format!("patch approval ignored because request id `{id}` was not pending"), + ); + return false; + }; + + let (request_id, result) = match pending_request { + PendingPatchApprovalRequest::V2(request_id) => { + let (decision, lossy) = file_change_approval_decision_from_review(decision); + if lossy { + send_warning_event( + app_event_tx, + "mapped unsupported patch decision to `accept` for v2 file-change approval" + .to_string(), + ); + } + let response = FileChangeRequestApprovalResponse { decision }; + let result = match serde_json::to_value(response) { + Ok(value) => value, + Err(err) => { + send_error_event( + app_event_tx, + format!("failed to encode patch approval response: {err}"), + ); + return false; + } + }; + (request_id, result) + } + PendingPatchApprovalRequest::V1(request_id) => { + let response = ApplyPatchApprovalResponse { decision }; + let result = match serde_json::to_value(response) { + Ok(value) => value, + Err(err) => { + send_error_event( + app_event_tx, + format!("failed to encode legacy patch approval response: {err}"), + ); + return false; + } + }; + (request_id, result) + } + }; + + resolve_server_request( + client, + request_id, + result, + "item/fileChange/requestApproval", + app_event_tx, + ) + .await; + } + Op::RequestPermissionsResponse { id, response } => { + let Some(request_id) = pending_server_requests + .request_permissions + .remove(&(thread_id.to_string(), id.clone())) + else { + send_warning_event( + app_event_tx, + format!( + "request_permissions response ignored because request id `{id}` was not pending" + ), + ); + return false; + }; + + let response = PermissionsRequestApprovalResponse { + permissions: granted_permission_profile_from_core(response.permissions), + scope: response.scope.into(), + }; + let result = match serde_json::to_value(response) { + Ok(value) => value, + Err(err) => { + send_error_event( + app_event_tx, + format!("failed to encode request_permissions response: {err}"), + ); + return false; + } + }; + resolve_server_request( + client, + request_id, + result, + "item/permissions/requestApproval", + app_event_tx, + ) + .await; + } + Op::UserInputAnswer { id, response } => { + let Some(request_id) = + pending_server_requests.pop_request_user_input_request_id(thread_id, &id) + else { + send_warning_event( + app_event_tx, + format!( + "request_user_input response ignored because turn `{id}` has no pending request" + ), + ); + return false; + }; + + let response = ToolRequestUserInputResponse { + answers: response + .answers + .into_iter() + .map(|(question_id, answer)| { + ( + question_id, + ToolRequestUserInputAnswer { + answers: answer.answers, + }, + ) + }) + .collect(), + }; + let result = match serde_json::to_value(response) { + Ok(value) => value, + Err(err) => { + send_error_event( + app_event_tx, + format!("failed to encode request_user_input response: {err}"), + ); + return false; + } + }; + resolve_server_request( + client, + request_id, + result, + "item/tool/requestUserInput", + app_event_tx, + ) + .await; + } + Op::DynamicToolResponse { id, response } => { + let Some(request_id) = pending_server_requests + .dynamic_tool_calls + .remove(&(thread_id.to_string(), id.clone())) + else { + send_warning_event( + app_event_tx, + format!( + "dynamic tool response ignored because request id `{id}` was not pending" + ), + ); + return false; + }; + let response = DynamicToolCallResponse { + content_items: response + .content_items + .into_iter() + .map( + |item| match item { + codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem::InputText { + text, + } => DynamicToolCallOutputContentItem::InputText { text }, + codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem::InputImage { + image_url, + } => DynamicToolCallOutputContentItem::InputImage { image_url }, + }, + ) + .collect(), + success: response.success, + }; + let result = match serde_json::to_value(response) { + Ok(value) => value, + Err(err) => { + send_error_event( + app_event_tx, + format!("failed to encode dynamic tool response: {err}"), + ); + return false; + } + }; + resolve_server_request(client, request_id, result, "item/tool/call", app_event_tx) + .await; + } + Op::AddToHistory { text } => { + if let Err(err) = append_history_entry_local(config, session_id, text).await { + send_warning_event( + app_event_tx, + format!("failed to append local history: {err}"), + ); + } + } + Op::GetHistoryEntryRequest { offset, log_id } => { + match read_history_entry_local(config, log_id, offset).await { + Ok(entry) => { + send_codex_event( + app_event_tx, + EventMsg::GetHistoryEntryResponse(GetHistoryEntryResponseEvent { + offset, + log_id, + entry, + }), + ); + } + Err(err) => { + send_warning_event( + app_event_tx, + format!("failed to read local history entry: {err}"), + ); + } + } + } + Op::ListCustomPrompts => { + let custom_prompts = + if let Some(dir) = codex_core::custom_prompts::default_prompts_dir() { + codex_core::custom_prompts::discover_prompts_in(&dir).await + } else { + Vec::new() + }; + send_codex_event( + app_event_tx, + EventMsg::ListCustomPromptsResponse(ListCustomPromptsResponseEvent { + custom_prompts, + }), + ); + } + Op::ReloadUserConfig => { + forward_op_to_thread( + thread_manager, + thread_id, + Op::ReloadUserConfig, + app_event_tx, + ) + .await; + } + Op::Undo => { + send_warning_event(app_event_tx, local_only_deferred_message("Undo")); + } + Op::OverrideTurnContext { + cwd, + approval_policy, + sandbox_policy, + windows_sandbox_level, + model, + effort, + summary, + service_tier, + collaboration_mode, + personality, + } => { + forward_op_to_thread( + thread_manager, + thread_id, + Op::OverrideTurnContext { + cwd, + approval_policy, + sandbox_policy, + windows_sandbox_level, + model, + effort, + summary, + service_tier, + collaboration_mode, + personality, + }, + app_event_tx, + ) + .await; + } + Op::DropMemories => { + forward_op_to_thread(thread_manager, thread_id, Op::DropMemories, app_event_tx).await; + } + Op::UpdateMemories => { + forward_op_to_thread(thread_manager, thread_id, Op::UpdateMemories, app_event_tx).await; + } + Op::RunUserShellCommand { command } => { + forward_op_to_thread( + thread_manager, + thread_id, + Op::RunUserShellCommand { command }, + app_event_tx, + ) + .await; + } + Op::ListMcpTools => { + forward_op_to_thread(thread_manager, thread_id, Op::ListMcpTools, app_event_tx).await; + } + Op::ResolveElicitation { + server_name, + request_id, + decision, + content, + meta, + } => { + let Some(pending_request_id) = pending_server_requests.pop_mcp_elicitation_request_id( + thread_id, + &server_name, + &request_id, + ) else { + send_warning_event( + app_event_tx, + format!( + "mcp elicitation response ignored because `{server_name}` request `{request_id}` was not pending" + ), + ); + return false; + }; + + let response = McpServerElicitationRequestResponse { + action: mcp_elicitation_action_to_protocol(decision), + content, + meta, + }; + let result = match serde_json::to_value(response) { + Ok(value) => value, + Err(err) => { + send_error_event( + app_event_tx, + format!("failed to encode mcp elicitation response: {err}"), + ); + return false; + } + }; + resolve_server_request( + client, + pending_request_id, + result, + "mcpServer/elicitation/request", + app_event_tx, + ) + .await; + } + Op::Shutdown => { + let request = ClientRequest::ThreadUnsubscribe { + request_id: request_ids.next(), + params: ThreadUnsubscribeParams { + thread_id: thread_id.to_string(), + }, + }; + if let Err(err) = send_request_with_response::( + client, + request, + "thread/unsubscribe", + ) + .await + { + send_warning_event( + app_event_tx, + format!("thread/unsubscribe failed during shutdown: {err}"), + ); + } + current_turn_ids.remove(thread_id); + pending_server_requests.clear_turn_scoped(thread_id); + return thread_id == primary_thread_id; + } + unsupported => { + send_warning_event( + app_event_tx, + format!( + "op `{}` is not routed through in-process app-server yet", + serde_json::to_value(&unsupported) + .ok() + .and_then(|value| { + value + .get("type") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + }) + .unwrap_or_else(|| "unknown".to_string()) + ), + ); + } + } + + false +} + +#[expect( + clippy::too_many_arguments, + reason = "agent loop keeps runtime state explicit" +)] +/// Runs the in-process TUI agent loop for a single active thread. +/// +/// This loop is responsible for keeping the TUI's existing `Op`-driven model +/// working on top of app-server. It forwards supported ops as typed +/// `ClientRequest`/`ClientNotification` messages, routes a small legacy subset +/// straight to the backing thread manager, translates server requests back +/// into UI events, and preserves thread-local bookkeeping such as current turn +/// id and pending approval state. +async fn run_in_process_agent_loop( + mut codex_op_rx: tokio::sync::mpsc::UnboundedReceiver, + mut thread_scoped_op_rx: tokio::sync::mpsc::UnboundedReceiver, + mut client: InProcessAppServerClient, + thread_manager: Arc, + config: Config, + thread_id: String, + mut session_configured: SessionConfiguredEvent, + app_event_tx: AppEventSender, + mut request_ids: RequestIdSequencer, + mut current_turn_ids: HashMap, +) { + let mut pending_shutdown_complete = false; + let mut pending_server_requests = PendingServerRequests::default(); + let session_id = session_configured.session_id; + loop { + tokio::select! { + maybe_op = codex_op_rx.recv() => { + match maybe_op { + Some(op) => { + let should_shutdown = process_in_process_command( + op, + &thread_id, + &thread_id, + None, + &session_id, + &config, + &mut current_turn_ids, + &mut request_ids, + &mut pending_server_requests, + &client, + thread_manager.as_ref(), + &app_event_tx, + ).await; + if should_shutdown { + pending_shutdown_complete = true; + break; + } + } + None => break, + } + } + maybe_thread_scoped_op = thread_scoped_op_rx.recv() => { + match maybe_thread_scoped_op { + Some(ThreadScopedOp { thread_id: scoped_thread_id, op, interrupt_turn_id }) => { + let scoped_thread_id = scoped_thread_id.to_string(); + let should_shutdown = process_in_process_command( + op, + &scoped_thread_id, + &thread_id, + interrupt_turn_id.as_deref(), + &session_id, + &config, + &mut current_turn_ids, + &mut request_ids, + &mut pending_server_requests, + &client, + thread_manager.as_ref(), + &app_event_tx, + ).await; + if should_shutdown { + pending_shutdown_complete = true; + break; + } + } + None => break, + } + } + maybe_event = client.next_event() => { + let Some(event) = maybe_event else { + break; + }; + + match event { + InProcessServerEvent::ServerRequest(request) => { + let method = server_request_method_name(&request); + match request { + ServerRequest::CommandExecutionRequestApproval { request_id, params } => { + let Ok(request_thread_id) = ThreadId::from_string(¶ms.thread_id) else { + reject_server_request( + &client, + request_id, + &method, + format!("request carried invalid thread id `{}`", params.thread_id), + &app_event_tx, + ) + .await; + continue; + }; + + let command = command_text_to_tokens(params.command.clone()); + let parsed_cmd = command_actions_to_core( + params.command_actions, + params.command.as_deref(), + ); + let approval_id = params + .approval_id + .clone() + .unwrap_or_else(|| params.item_id.clone()); + pending_server_requests.exec_approvals.insert( + (params.thread_id.clone(), approval_id), + PendingExecApprovalRequest::V2(request_id), + ); + send_routed_codex_event( + &app_event_tx, + session_id, + request_thread_id, + EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { + call_id: params.item_id, + approval_id: params.approval_id, + turn_id: params.turn_id, + command, + cwd: params.cwd.unwrap_or_default(), + reason: params.reason, + network_approval_context: params + .network_approval_context + .map(network_approval_context_to_core), + proposed_execpolicy_amendment: params + .proposed_execpolicy_amendment + .map(codex_app_server_protocol::ExecPolicyAmendment::into_core), + proposed_network_policy_amendments: params + .proposed_network_policy_amendments + .map(|items| { + items + .into_iter() + .map(codex_app_server_protocol::NetworkPolicyAmendment::into_core) + .collect() + }), + additional_permissions: params + .additional_permissions + .map(additional_permission_profile_to_core), + skill_metadata: params + .skill_metadata + .map(|metadata| { + codex_protocol::protocol::ExecApprovalRequestSkillMetadata { + path_to_skills_md: metadata.path_to_skills_md, + } + }), + available_decisions: command_execution_available_decisions_to_core( + params.available_decisions, + ), + parsed_cmd, + }), + ); + } + ServerRequest::ExecCommandApproval { request_id, params } => { + let approval_id = params + .approval_id + .clone() + .unwrap_or_else(|| params.call_id.clone()); + pending_server_requests.exec_approvals.insert( + (params.conversation_id.to_string(), approval_id), + PendingExecApprovalRequest::V1(request_id), + ); + send_routed_codex_event( + &app_event_tx, + session_id, + params.conversation_id, + EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { + call_id: params.call_id, + approval_id: params.approval_id, + turn_id: String::new(), + command: params.command, + cwd: params.cwd, + reason: params.reason, + network_approval_context: None, + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + additional_permissions: None, + skill_metadata: None, + available_decisions: None, + parsed_cmd: params.parsed_cmd, + }), + ); + } + ServerRequest::FileChangeRequestApproval { request_id, params } => { + let Ok(request_thread_id) = ThreadId::from_string(¶ms.thread_id) else { + reject_server_request( + &client, + request_id, + &method, + format!("request carried invalid thread id `{}`", params.thread_id), + &app_event_tx, + ) + .await; + continue; + }; + + let changes = pending_server_requests + .take_file_changes(¶ms.thread_id, ¶ms.item_id); + pending_server_requests.patch_approvals.insert( + (params.thread_id.clone(), params.item_id.clone()), + PendingPatchApprovalRequest::V2(request_id), + ); + send_routed_codex_event( + &app_event_tx, + session_id, + request_thread_id, + EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { + call_id: params.item_id, + turn_id: params.turn_id, + changes, + reason: params.reason, + grant_root: params.grant_root, + }), + ); + } + ServerRequest::ApplyPatchApproval { request_id, params } => { + pending_server_requests.patch_approvals.insert( + (params.conversation_id.to_string(), params.call_id.clone()), + PendingPatchApprovalRequest::V1(request_id), + ); + send_routed_codex_event( + &app_event_tx, + session_id, + params.conversation_id, + EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { + call_id: params.call_id, + turn_id: String::new(), + changes: params.file_changes, + reason: params.reason, + grant_root: params.grant_root, + }), + ); + } + ServerRequest::ToolRequestUserInput { request_id, params } => { + let Ok(request_thread_id) = ThreadId::from_string(¶ms.thread_id) else { + reject_server_request( + &client, + request_id, + &method, + format!("request carried invalid thread id `{}`", params.thread_id), + &app_event_tx, + ) + .await; + continue; + }; + + pending_server_requests + .register_request_user_input( + params.thread_id.clone(), + params.turn_id.clone(), + request_id, + ); + send_routed_codex_event( + &app_event_tx, + session_id, + request_thread_id, + EventMsg::RequestUserInput(RequestUserInputEvent { + call_id: params.item_id, + turn_id: params.turn_id, + questions: request_user_input_questions_to_core( + params.questions, + ), + }), + ); + } + ServerRequest::PermissionsRequestApproval { request_id, params } => { + let Ok(request_thread_id) = ThreadId::from_string(¶ms.thread_id) else { + reject_server_request( + &client, + request_id, + &method, + format!("request carried invalid thread id `{}`", params.thread_id), + &app_event_tx, + ) + .await; + continue; + }; + + pending_server_requests + .request_permissions + .insert((params.thread_id.clone(), params.item_id.clone()), request_id); + send_routed_codex_event( + &app_event_tx, + session_id, + request_thread_id, + EventMsg::RequestPermissions(RequestPermissionsEvent { + call_id: params.item_id, + turn_id: params.turn_id, + reason: params.reason, + permissions: additional_permission_profile_to_core( + params.permissions, + ), + }), + ); + } + ServerRequest::McpServerElicitationRequest { request_id, params } => { + let Ok(request_thread_id) = ThreadId::from_string(¶ms.thread_id) else { + reject_server_request( + &client, + request_id, + &method, + format!("request carried invalid thread id `{}`", params.thread_id), + &app_event_tx, + ) + .await; + continue; + }; + + let elicitation_id = app_server_request_id_to_mcp(request_id.clone()); + pending_server_requests.register_mcp_elicitation( + params.thread_id.clone(), + request_id, + params.server_name.clone(), + elicitation_id.clone(), + ); + send_routed_codex_event( + &app_event_tx, + session_id, + request_thread_id, + EventMsg::ElicitationRequest(ElicitationRequestEvent { + turn_id: params.turn_id, + server_name: params.server_name, + id: elicitation_id, + request: mcp_elicitation_request_to_core(params.request), + }), + ); + } + ServerRequest::DynamicToolCall { request_id, params } => { + let Ok(request_thread_id) = ThreadId::from_string(¶ms.thread_id) else { + reject_server_request( + &client, + request_id, + &method, + format!("request carried invalid thread id `{}`", params.thread_id), + &app_event_tx, + ) + .await; + continue; + }; + + pending_server_requests + .dynamic_tool_calls + .insert((params.thread_id.clone(), params.call_id.clone()), request_id); + send_routed_codex_event( + &app_event_tx, + session_id, + request_thread_id, + EventMsg::DynamicToolCallRequest(DynamicToolCallRequest { + call_id: params.call_id, + turn_id: params.turn_id, + tool: params.tool, + arguments: params.arguments, + }), + ); + } + ServerRequest::ChatgptAuthTokensRefresh { request_id, params } => { + match local_external_chatgpt_tokens(thread_manager.auth_manager()) + .await + { + Err(reason) => { + reject_server_request( + &client, + request_id, + &method, + format!( + "local chatgpt auth refresh failed in in-process TUI: {reason}" + ), + &app_event_tx, + ) + .await; + } + Ok(response) => { + if let Err(reason) = validate_refreshed_chatgpt_account( + params.previous_account_id.as_deref(), + &response.chatgpt_account_id, + ) { + send_warning_event(&app_event_tx, reason.clone()); + reject_server_request( + &client, + request_id, + &method, + reason, + &app_event_tx, + ) + .await; + continue; + } + + let value = match serde_json::to_value(response) { + Ok(value) => value, + Err(err) => { + let reason = format!( + "failed to serialize chatgpt auth refresh response: {err}" + ); + send_error_event( + &app_event_tx, + reason.clone(), + ); + reject_server_request( + &client, + request_id, + &method, + reason, + &app_event_tx, + ) + .await; + continue; + } + }; + resolve_server_request( + &client, + request_id, + value, + "account/chatgptAuthTokens/refresh", + &app_event_tx, + ) + .await; + } + } + } + } + } + InProcessServerEvent::ServerNotification(notification) => { + match notification { + ServerNotification::ItemStarted(notification) => { + if let ThreadItem::FileChange { id, changes, .. } = notification.item + { + pending_server_requests + .note_file_changes( + notification.thread_id, + id, + file_update_changes_to_core(changes), + ); + } + } + ServerNotification::ServerRequestResolved(notification) => { + pending_server_requests.clear_resolved_request_id( + ¬ification.thread_id, + ¬ification.request_id, + ); + } + ServerNotification::ThreadClosed(notification) => { + let Ok(closed_thread_id) = + ThreadId::from_string(¬ification.thread_id) + else { + send_warning_event( + &app_event_tx, + format!( + "thread/closed carried invalid thread id `{}`", + notification.thread_id + ), + ); + continue; + }; + current_turn_ids.remove(¬ification.thread_id); + pending_server_requests.clear_turn_scoped(¬ification.thread_id); + if closed_thread_id == session_id { + pending_shutdown_complete = true; + break; + } + app_event_tx.send(AppEvent::ThreadEvent { + thread_id: closed_thread_id, + event: Event { + id: String::new(), + msg: EventMsg::ShutdownComplete, + }, + }); + } + _ => {} + } + } + InProcessServerEvent::LegacyNotification(notification) => { + let decoded = match decode_legacy_notification(notification) { + Ok(decoded) => decoded, + Err(err) => { + send_warning_event(&app_event_tx, err); + continue; + } + }; + let event = decoded.event; + if let EventMsg::SessionConfigured(update) = event.msg { + match decoded.conversation_id { + Some(thread_id) if thread_id != session_id => { + app_event_tx.send(AppEvent::ThreadEvent { + thread_id, + event: Event { + id: event.id, + msg: EventMsg::SessionConfigured(update), + }, + }); + } + _ => { + if let Some(merged) = + merge_session_configured_update(&session_configured, update) + { + session_configured = merged.clone(); + app_event_tx.send(AppEvent::CodexEvent(Event { + id: event.id, + msg: EventMsg::SessionConfigured(merged), + })); + } + } + } + continue; + } + + let shutdown_complete = note_primary_legacy_event( + session_id, + decoded.conversation_id, + &event, + &mut current_turn_ids, + &mut pending_server_requests, + ); + if shutdown_complete { + pending_shutdown_complete = true; + break; + } + match decoded.conversation_id { + Some(thread_id) if thread_id != session_id => { + app_event_tx.send(AppEvent::ThreadEvent { thread_id, event }); + } + _ => { + app_event_tx.send(AppEvent::CodexEvent(event)); + } + } + } + InProcessServerEvent::Lagged { skipped } => { + send_warning_event(&app_event_tx, lagged_event_warning_message(skipped)); + } + } + } + } + } + + finalize_in_process_shutdown(client.shutdown(), app_event_tx, pending_shutdown_complete).await; +} + +/// Spawn the agent bootstrapper and op forwarding loop, returning the +/// `UnboundedSender` used by the UI to submit operations. +pub(crate) fn spawn_agent( + config: Config, + app_event_tx: AppEventSender, + server: Arc, + in_process_context: InProcessAgentContext, +) -> (UnboundedSender, UnboundedSender) { + let (codex_op_tx, codex_op_rx) = unbounded_channel::(); + let (thread_scoped_op_tx, thread_scoped_op_rx) = unbounded_channel::(); + + let app_event_tx_clone = app_event_tx; + tokio::spawn(async move { + let mut request_ids = RequestIdSequencer::new(); + let thread_manager = Arc::clone(&server); + let client = match InProcessAppServerClient::start(in_process_start_args( + &config, + server, + in_process_context.arg0_paths, + in_process_context.cli_kv_overrides, + in_process_context.cloud_requirements, + )) + .await + { + Ok(client) => client, + Err(err) => { + let message = format!("Failed to initialize in-process app-server client: {err}"); + tracing::error!("{message}"); + send_error_event(&app_event_tx_clone, message.clone()); + app_event_tx_clone.send(AppEvent::FatalExitRequest(message)); + return; + } + }; + + let thread_start = match send_request_with_response::( + &client, + ClientRequest::ThreadStart { + request_id: request_ids.next(), + params: thread_start_params_from_config(&config), + }, + "thread/start", + ) + .await + { + Ok(response) => response, + Err(err) => { + send_error_event(&app_event_tx_clone, err.clone()); + app_event_tx_clone.send(AppEvent::FatalExitRequest(err)); + let _ = client.shutdown().await; + return; + } + }; + + let session_configured = + match session_configured_from_thread_start_response(&config, thread_start).await { + Ok(event) => event, + Err(message) => { + send_error_event(&app_event_tx_clone, message.clone()); + app_event_tx_clone.send(AppEvent::FatalExitRequest(message)); + let _ = client.shutdown().await; + return; + } + }; + + let thread_id = session_configured.session_id.to_string(); + send_codex_event( + &app_event_tx_clone, + EventMsg::SessionConfigured(session_configured.clone()), + ); + + run_in_process_agent_loop( + codex_op_rx, + thread_scoped_op_rx, + client, + thread_manager, + config, + thread_id, + session_configured, + app_event_tx_clone, + request_ids, + HashMap::new(), + ) + .await; + }); + + (codex_op_tx, thread_scoped_op_tx) +} + +/// Spawn an op-forwarding loop for an existing thread without subscribing to events. +pub(crate) fn spawn_op_forwarder(thread: std::sync::Arc) -> UnboundedSender { + let (codex_op_tx, mut codex_op_rx) = unbounded_channel::(); + + tokio::spawn(async move { + initialize_app_server_client_name(thread.as_ref()).await; + while let Some(op) = codex_op_rx.recv().await { + if let Err(e) = thread.submit(op).await { + tracing::error!("failed to submit op: {e}"); + } + } + }); + + codex_op_tx +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use base64::Engine; + use codex_core::auth::ExternalAuthRefreshContext; + use codex_core::auth::ExternalAuthRefresher; + use codex_core::auth::ExternalAuthTokens; + use codex_core::auth::login_with_chatgpt_auth_tokens; + use codex_core::config::ConfigBuilder; + use codex_protocol::protocol::ConversationAudioParams; + use codex_protocol::protocol::ConversationStartParams; + use codex_protocol::protocol::ConversationTextParams; + use codex_protocol::protocol::RealtimeAudioFrame; + use codex_protocol::protocol::TurnCompleteEvent; + use codex_protocol::protocol::TurnStartedEvent; + use pretty_assertions::assert_eq; + use std::sync::Mutex; + use tempfile::TempDir; + use tokio::sync::mpsc::unbounded_channel; + use tokio::sync::oneshot; + use tokio::time::Duration; + use tokio::time::timeout; + + async fn test_config() -> Config { + ConfigBuilder::default() + .codex_home(std::env::temp_dir()) + .build() + .await + .expect("config") + } + + fn test_thread_manager(config: &Config) -> Arc { + Arc::new( + codex_core::test_support::thread_manager_with_models_provider_and_home( + codex_core::CodexAuth::from_api_key("test"), + config.model_provider.clone(), + config.codex_home.clone(), + ), + ) + } + + struct RecordingExternalAuthRefresher { + refreshed: ExternalAuthTokens, + contexts: Mutex>, + } + + #[async_trait] + impl ExternalAuthRefresher for RecordingExternalAuthRefresher { + async fn refresh( + &self, + context: ExternalAuthRefreshContext, + ) -> std::io::Result { + self.contexts.lock().expect("contexts mutex").push(context); + Ok(self.refreshed.clone()) + } + } + + struct FailingExternalAuthRefresher; + + #[async_trait] + impl ExternalAuthRefresher for FailingExternalAuthRefresher { + async fn refresh( + &self, + _context: ExternalAuthRefreshContext, + ) -> std::io::Result { + Err(std::io::Error::other( + "override refresher should not be used during local refresh", + )) + } + } + + async fn assert_realtime_op_reports_expected_method(op: Op, expected_method: &str) { + let config = test_config().await; + let session_id = ThreadId::new(); + let thread_manager = test_thread_manager(&config); + let client = InProcessAppServerClient::start(in_process_start_args( + &config, + Arc::clone(&thread_manager), + codex_arg0::Arg0DispatchPaths::default(), + Vec::new(), + CloudRequirementsLoader::default(), + )) + .await + .expect("in-process app-server client"); + let (tx, mut rx) = unbounded_channel(); + let app_event_tx = AppEventSender::new(tx); + let mut current_turn_ids = HashMap::new(); + let mut request_ids = RequestIdSequencer::new(); + let mut pending_server_requests = PendingServerRequests::default(); + + let should_shutdown = process_in_process_command( + op, + "missing-thread-id", + "missing-thread-id", + None, + &session_id, + &config, + &mut current_turn_ids, + &mut request_ids, + &mut pending_server_requests, + &client, + thread_manager.as_ref(), + &app_event_tx, + ) + .await; + assert_eq!(should_shutdown, false); + + let maybe_event = timeout(Duration::from_secs(2), rx.recv()) + .await + .expect("timed out waiting for app event"); + let event = maybe_event.expect("expected app event"); + let AppEvent::CodexEvent(event) = event else { + panic!("expected codex event"); + }; + let EventMsg::Error(error_event) = event.msg else { + panic!("expected error event"); + }; + assert_eq!(error_event.codex_error_info, None); + assert!( + error_event.message.contains(expected_method), + "expected error message to contain `{expected_method}`, got `{}`", + error_event.message + ); + + client.shutdown().await.expect("shutdown in-process client"); + } + + async fn process_single_op( + config: &Config, + op: Op, + ) -> ( + bool, + tokio::sync::mpsc::UnboundedReceiver, + InProcessAppServerClient, + Arc, + ThreadId, + ) { + let thread_manager = test_thread_manager(config); + let client = InProcessAppServerClient::start(in_process_start_args( + config, + Arc::clone(&thread_manager), + codex_arg0::Arg0DispatchPaths::default(), + Vec::new(), + CloudRequirementsLoader::default(), + )) + .await + .expect("in-process app-server client"); + let (tx, rx) = unbounded_channel(); + let app_event_tx = AppEventSender::new(tx); + let mut current_turn_ids = HashMap::new(); + let mut request_ids = RequestIdSequencer::new(); + let mut pending_server_requests = PendingServerRequests::default(); + let thread_start = send_request_with_response::( + &client, + ClientRequest::ThreadStart { + request_id: request_ids.next(), + params: ThreadStartParams::default(), + }, + "thread/start", + ) + .await + .expect("thread/start"); + let thread_id = thread_start.thread.id; + let session_id = ThreadId::from_string(&thread_id).expect("valid thread id"); + let should_shutdown = process_in_process_command( + op, + &thread_id, + &thread_id, + None, + &session_id, + config, + &mut current_turn_ids, + &mut request_ids, + &mut pending_server_requests, + &client, + thread_manager.as_ref(), + &app_event_tx, + ) + .await; + (should_shutdown, rx, client, thread_manager, session_id) + } + + #[tokio::test] + async fn pending_shutdown_complete_emits_shutdown_complete_before_shutdown_finishes() { + let (tx, mut rx) = unbounded_channel(); + let app_event_tx = AppEventSender::new(tx); + let (shutdown_started_tx, shutdown_started_rx) = oneshot::channel(); + let (shutdown_release_tx, shutdown_release_rx) = oneshot::channel(); + + finalize_in_process_shutdown( + async move { + let _ = shutdown_started_tx.send(()); + let _ = shutdown_release_rx.await; + Ok(()) + }, + app_event_tx, + true, + ) + .await; + + timeout(Duration::from_secs(2), shutdown_started_rx) + .await + .expect("timed out waiting for background shutdown to start") + .expect("expected background shutdown start signal"); + + let event = timeout(Duration::from_secs(2), rx.recv()) + .await + .expect("timed out waiting for shutdown complete event") + .expect("expected app event"); + let AppEvent::CodexEvent(event) = event else { + panic!("expected codex event"); + }; + assert!( + matches!(event.msg, EventMsg::ShutdownComplete), + "expected shutdown complete event" + ); + assert!(rx.try_recv().is_err(), "expected no additional app events"); + + let _ = shutdown_release_tx.send(()); + } + + fn fake_external_access_token(plan_type: &str) -> String { + #[derive(serde::Serialize)] + struct Header { + alg: &'static str, + typ: &'static str, + } + + fn b64url_no_pad(bytes: &[u8]) -> String { + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) + } + + let header = Header { + alg: "none", + typ: "JWT", + }; + let payload = serde_json::json!({ + "https://api.openai.com/auth": { + "chatgpt_plan_type": plan_type, + } + }); + + let header_b64 = b64url_no_pad( + &serde_json::to_vec(&header).expect("serialize fake jwt header for test"), + ); + let payload_b64 = b64url_no_pad( + &serde_json::to_vec(&payload).expect("serialize fake jwt payload for test"), + ); + let signature_b64 = b64url_no_pad(b"sig"); + format!("{header_b64}.{payload_b64}.{signature_b64}") + } + + async fn next_codex_event( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, + ) -> codex_protocol::protocol::Event { + let maybe_event = timeout(Duration::from_secs(2), rx.recv()) + .await + .expect("timed out waiting for app event"); + let event = maybe_event.expect("expected app event"); + let AppEvent::CodexEvent(event) = event else { + panic!("expected codex event"); + }; + event + } + + #[test] + fn send_routed_codex_event_keeps_primary_thread_events_on_primary_bus() { + let session_id = ThreadId::new(); + let (tx, mut rx) = unbounded_channel(); + let app_event_tx = AppEventSender::new(tx); + + send_routed_codex_event( + &app_event_tx, + session_id, + session_id, + EventMsg::Warning(WarningEvent { + message: "primary".to_string(), + }), + ); + + let event = rx.try_recv().expect("expected app event"); + let AppEvent::CodexEvent(event) = event else { + panic!("expected primary codex event"); + }; + let EventMsg::Warning(warning) = event.msg else { + panic!("expected warning event"); + }; + assert_eq!(warning.message, "primary".to_string()); + } + + #[test] + fn send_routed_codex_event_routes_child_thread_events() { + let session_id = ThreadId::new(); + let child_thread_id = ThreadId::new(); + let (tx, mut rx) = unbounded_channel(); + let app_event_tx = AppEventSender::new(tx); + + send_routed_codex_event( + &app_event_tx, + session_id, + child_thread_id, + EventMsg::Warning(WarningEvent { + message: "child".to_string(), + }), + ); + + let event = rx.try_recv().expect("expected app event"); + let AppEvent::ThreadEvent { thread_id, event } = event else { + panic!("expected routed thread event"); + }; + assert_eq!(thread_id, child_thread_id); + let EventMsg::Warning(warning) = event.msg else { + panic!("expected warning event"); + }; + assert_eq!(warning.message, "child".to_string()); + } + + #[test] + fn send_routed_codex_event_routes_child_dynamic_tool_call_requests() { + let session_id = ThreadId::new(); + let child_thread_id = ThreadId::new(); + let (tx, mut rx) = unbounded_channel(); + let app_event_tx = AppEventSender::new(tx); + + send_routed_codex_event( + &app_event_tx, + session_id, + child_thread_id, + EventMsg::DynamicToolCallRequest(DynamicToolCallRequest { + call_id: "call-1".to_string(), + turn_id: "turn-1".to_string(), + tool: "demo".to_string(), + arguments: serde_json::json!({ "value": 1 }), + }), + ); + + let event = rx.try_recv().expect("expected app event"); + let AppEvent::ThreadEvent { thread_id, event } = event else { + panic!("expected routed thread event"); + }; + assert_eq!(thread_id, child_thread_id); + let EventMsg::DynamicToolCallRequest(request) = event.msg else { + panic!("expected dynamic tool call request event"); + }; + assert_eq!(request.call_id, "call-1".to_string()); + assert_eq!(request.turn_id, "turn-1".to_string()); + assert_eq!(request.tool, "demo".to_string()); + assert_eq!(request.arguments, serde_json::json!({ "value": 1 })); + } + + fn warning_from_event(event: codex_protocol::protocol::Event) -> WarningEvent { + let EventMsg::Warning(warning) = event.msg else { + panic!("expected warning event"); + }; + warning + } + + #[test] + fn clear_turn_scoped_preserves_pending_mcp_elicitation_requests() { + let mut pending = PendingServerRequests::default(); + let thread_id = "thread-1".to_string(); + let pending_request_id = RequestId::Integer(42); + let server_name = "test-server".to_string(); + let elicitation_id = codex_protocol::mcp::RequestId::Integer(7); + pending.register_mcp_elicitation( + thread_id.clone(), + pending_request_id.clone(), + server_name.clone(), + elicitation_id.clone(), + ); + + pending.clear_turn_scoped(&thread_id); + + assert_eq!( + pending.pop_mcp_elicitation_request_id(&thread_id, &server_name, &elicitation_id), + Some(pending_request_id) + ); + } + + #[test] + fn clear_turn_scoped_only_clears_requests_for_target_thread() { + let mut pending = PendingServerRequests::default(); + pending.exec_approvals.insert( + ("thread-a".to_string(), "exec-a".to_string()), + PendingExecApprovalRequest::V2(RequestId::Integer(1)), + ); + pending.exec_approvals.insert( + ("thread-b".to_string(), "exec-b".to_string()), + PendingExecApprovalRequest::V2(RequestId::Integer(2)), + ); + pending.request_permissions.insert( + ("thread-a".to_string(), "perm-a".to_string()), + RequestId::Integer(3), + ); + pending.request_permissions.insert( + ("thread-b".to_string(), "perm-b".to_string()), + RequestId::Integer(4), + ); + pending.register_request_user_input( + "thread-a".to_string(), + "turn-a".to_string(), + RequestId::Integer(5), + ); + pending.register_request_user_input( + "thread-b".to_string(), + "turn-b".to_string(), + RequestId::Integer(6), + ); + pending.dynamic_tool_calls.insert( + ("thread-a".to_string(), "tool-a".to_string()), + RequestId::Integer(7), + ); + pending.dynamic_tool_calls.insert( + ("thread-b".to_string(), "tool-b".to_string()), + RequestId::Integer(8), + ); + + pending.clear_turn_scoped("thread-a"); + + assert_eq!( + pending.exec_approvals, + HashMap::from([( + ("thread-b".to_string(), "exec-b".to_string()), + PendingExecApprovalRequest::V2(RequestId::Integer(2)), + )]) + ); + assert_eq!( + pending.request_permissions, + HashMap::from([( + ("thread-b".to_string(), "perm-b".to_string()), + RequestId::Integer(4), + )]) + ); + assert_eq!( + pending.request_user_input, + HashMap::from([( + ("thread-b".to_string(), "turn-b".to_string()), + VecDeque::from([RequestId::Integer(6)]), + )]) + ); + assert_eq!( + pending.dynamic_tool_calls, + HashMap::from([( + ("thread-b".to_string(), "tool-b".to_string()), + RequestId::Integer(8), + )]) + ); + } + + #[test] + fn child_legacy_turn_events_do_not_mutate_primary_turn_state() { + let session_id = ThreadId::new(); + let child_thread_id = ThreadId::new(); + let mut current_turn_ids = + HashMap::from([(session_id.to_string(), "primary-turn".to_string())]); + let mut pending_server_requests = PendingServerRequests::default(); + pending_server_requests.exec_approvals.insert( + (session_id.to_string(), "exec-1".to_string()), + PendingExecApprovalRequest::V2(RequestId::Integer(1)), + ); + pending_server_requests.request_permissions.insert( + (session_id.to_string(), "perm-1".to_string()), + RequestId::Integer(2), + ); + pending_server_requests.register_request_user_input( + session_id.to_string(), + "primary-turn".to_string(), + RequestId::Integer(3), + ); + pending_server_requests.dynamic_tool_calls.insert( + (session_id.to_string(), "tool-1".to_string()), + RequestId::Integer(4), + ); + + let child_turn_started = Event { + id: "child-turn-started".to_string(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "child-turn".to_string(), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + }; + assert_eq!( + note_primary_legacy_event( + session_id, + Some(child_thread_id), + &child_turn_started, + &mut current_turn_ids, + &mut pending_server_requests, + ), + false + ); + assert_eq!( + current_turn_ids.get(&session_id.to_string()), + Some(&"primary-turn".to_string()) + ); + assert_eq!( + current_turn_ids.get(&child_thread_id.to_string()), + Some(&"child-turn".to_string()) + ); + assert_eq!( + pending_server_requests.exec_approvals.len(), + 1, + "child turn start should not clear primary exec approvals" + ); + assert_eq!( + pending_server_requests + .request_permissions + .get(&(session_id.to_string(), "perm-1".to_string())), + Some(&RequestId::Integer(2)) + ); + assert_eq!( + pending_server_requests + .request_user_input + .get(&(session_id.to_string(), "primary-turn".to_string())) + .map(VecDeque::len), + Some(1) + ); + assert_eq!( + pending_server_requests + .dynamic_tool_calls + .get(&(session_id.to_string(), "tool-1".to_string())), + Some(&RequestId::Integer(4)) + ); + + let child_turn_complete = Event { + id: "child-turn-complete".to_string(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "child-turn".to_string(), + last_agent_message: None, + }), + }; + assert_eq!( + note_primary_legacy_event( + session_id, + Some(child_thread_id), + &child_turn_complete, + &mut current_turn_ids, + &mut pending_server_requests, + ), + false + ); + assert_eq!( + current_turn_ids.get(&session_id.to_string()), + Some(&"primary-turn".to_string()) + ); + assert_eq!(current_turn_ids.get(&child_thread_id.to_string()), None); + assert_eq!( + pending_server_requests.exec_approvals.len(), + 1, + "child turn completion should not clear primary exec approvals" + ); + assert_eq!( + pending_server_requests + .request_permissions + .get(&(session_id.to_string(), "perm-1".to_string())), + Some(&RequestId::Integer(2)) + ); + assert_eq!( + pending_server_requests + .request_user_input + .get(&(session_id.to_string(), "primary-turn".to_string())) + .map(VecDeque::len), + Some(1) + ); + assert_eq!( + pending_server_requests + .dynamic_tool_calls + .get(&(session_id.to_string(), "tool-1".to_string())), + Some(&RequestId::Integer(4)) + ); + } + + #[test] + fn primary_turn_completion_preserves_child_pending_requests() { + let session_id = ThreadId::new(); + let child_thread_id = ThreadId::new(); + let mut current_turn_ids = HashMap::from([ + (session_id.to_string(), "primary-turn".to_string()), + (child_thread_id.to_string(), "child-turn".to_string()), + ]); + let mut pending_server_requests = PendingServerRequests::default(); + pending_server_requests.exec_approvals.insert( + (session_id.to_string(), "exec-primary".to_string()), + PendingExecApprovalRequest::V2(RequestId::Integer(1)), + ); + pending_server_requests.exec_approvals.insert( + (child_thread_id.to_string(), "exec-child".to_string()), + PendingExecApprovalRequest::V2(RequestId::Integer(2)), + ); + pending_server_requests.request_permissions.insert( + (session_id.to_string(), "perm-primary".to_string()), + RequestId::Integer(3), + ); + pending_server_requests.request_permissions.insert( + (child_thread_id.to_string(), "perm-child".to_string()), + RequestId::Integer(4), + ); + pending_server_requests.register_request_user_input( + session_id.to_string(), + "primary-turn".to_string(), + RequestId::Integer(5), + ); + pending_server_requests.register_request_user_input( + child_thread_id.to_string(), + "child-turn".to_string(), + RequestId::Integer(6), + ); + + assert_eq!( + note_primary_legacy_event( + session_id, + None, + &Event { + id: "primary-turn-complete".to_string(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "primary-turn".to_string(), + last_agent_message: None, + }), + }, + &mut current_turn_ids, + &mut pending_server_requests, + ), + false + ); + + assert_eq!(current_turn_ids.get(&session_id.to_string()), None); + assert_eq!( + current_turn_ids.get(&child_thread_id.to_string()), + Some(&"child-turn".to_string()) + ); + assert_eq!( + pending_server_requests.exec_approvals, + HashMap::from([( + (child_thread_id.to_string(), "exec-child".to_string()), + PendingExecApprovalRequest::V2(RequestId::Integer(2)), + )]) + ); + assert_eq!( + pending_server_requests.request_permissions, + HashMap::from([( + (child_thread_id.to_string(), "perm-child".to_string()), + RequestId::Integer(4), + )]) + ); + assert_eq!( + pending_server_requests.request_user_input, + HashMap::from([( + (child_thread_id.to_string(), "child-turn".to_string()), + VecDeque::from([RequestId::Integer(6)]), + )]) + ); + } + + #[test] + fn pending_file_changes_are_scoped_by_thread() { + let mut pending_server_requests = PendingServerRequests::default(); + let item_id = "patch-1"; + let main_thread_id = "thread-main"; + let child_thread_id = "thread-child"; + let main_changes = HashMap::from([( + PathBuf::from("main.txt"), + FileChange::Add { + content: "main".to_string(), + }, + )]); + let child_changes = HashMap::from([( + PathBuf::from("child.txt"), + FileChange::Add { + content: "child".to_string(), + }, + )]); + + pending_server_requests.note_file_changes( + main_thread_id.to_string(), + item_id.to_string(), + main_changes.clone(), + ); + pending_server_requests.note_file_changes( + child_thread_id.to_string(), + item_id.to_string(), + child_changes.clone(), + ); + + assert_eq!( + pending_server_requests.take_file_changes(child_thread_id, item_id), + child_changes + ); + assert_eq!( + pending_server_requests.take_file_changes(main_thread_id, item_id), + main_changes + ); + } + + #[test] + fn server_request_resolved_clears_pending_mcp_elicitation_request() { + let mut pending = PendingServerRequests::default(); + let thread_id = "thread-1".to_string(); + let pending_request_id = RequestId::Integer(5); + let server_name = "test-server".to_string(); + let elicitation_id = codex_protocol::mcp::RequestId::String("abc".to_string()); + pending.register_mcp_elicitation( + thread_id.clone(), + pending_request_id.clone(), + server_name.clone(), + elicitation_id.clone(), + ); + + pending.clear_mcp_elicitation_by_request_id(&pending_request_id); + + assert_eq!( + pending.pop_mcp_elicitation_request_id(&thread_id, &server_name, &elicitation_id), + None + ); + } + + #[test] + fn server_request_resolved_clears_pending_request_permissions_and_user_input() { + let mut pending = PendingServerRequests::default(); + pending.request_permissions.insert( + ("thread-1".to_string(), "perm-1".to_string()), + RequestId::Integer(5), + ); + pending.register_request_user_input( + "thread-1".to_string(), + "turn-1".to_string(), + RequestId::Integer(6), + ); + pending.register_request_user_input( + "thread-1".to_string(), + "turn-1".to_string(), + RequestId::Integer(7), + ); + + pending.clear_resolved_request_id("thread-1", &RequestId::Integer(5)); + pending.clear_resolved_request_id("thread-1", &RequestId::Integer(6)); + + assert_eq!(pending.request_permissions.len(), 0); + assert_eq!( + pending.pop_request_user_input_request_id("thread-1", "turn-1"), + Some(RequestId::Integer(7)) + ); + } + + #[test] + fn pending_request_lookups_are_scoped_by_thread() { + let mut pending = PendingServerRequests::default(); + + pending.exec_approvals.insert( + ("thread-a".to_string(), "exec-1".to_string()), + PendingExecApprovalRequest::V2(RequestId::Integer(1)), + ); + pending.exec_approvals.insert( + ("thread-b".to_string(), "exec-1".to_string()), + PendingExecApprovalRequest::V2(RequestId::Integer(2)), + ); + pending.patch_approvals.insert( + ("thread-a".to_string(), "patch-1".to_string()), + PendingPatchApprovalRequest::V2(RequestId::Integer(3)), + ); + pending.patch_approvals.insert( + ("thread-b".to_string(), "patch-1".to_string()), + PendingPatchApprovalRequest::V2(RequestId::Integer(4)), + ); + pending.request_permissions.insert( + ("thread-a".to_string(), "perm-1".to_string()), + RequestId::Integer(5), + ); + pending.request_permissions.insert( + ("thread-b".to_string(), "perm-1".to_string()), + RequestId::Integer(6), + ); + pending.register_request_user_input( + "thread-a".to_string(), + "turn-1".to_string(), + RequestId::Integer(7), + ); + pending.register_request_user_input( + "thread-b".to_string(), + "turn-1".to_string(), + RequestId::Integer(8), + ); + pending.dynamic_tool_calls.insert( + ("thread-a".to_string(), "tool-1".to_string()), + RequestId::Integer(9), + ); + pending.dynamic_tool_calls.insert( + ("thread-b".to_string(), "tool-1".to_string()), + RequestId::Integer(10), + ); + pending.register_mcp_elicitation( + "thread-a".to_string(), + RequestId::Integer(11), + "server".to_string(), + codex_protocol::mcp::RequestId::Integer(12), + ); + pending.register_mcp_elicitation( + "thread-b".to_string(), + RequestId::Integer(13), + "server".to_string(), + codex_protocol::mcp::RequestId::Integer(12), + ); + + assert_eq!( + pending + .exec_approvals + .remove(&("thread-b".to_string(), "exec-1".to_string())), + Some(PendingExecApprovalRequest::V2(RequestId::Integer(2))) + ); + assert_eq!( + pending + .patch_approvals + .remove(&("thread-b".to_string(), "patch-1".to_string())), + Some(PendingPatchApprovalRequest::V2(RequestId::Integer(4))) + ); + assert_eq!( + pending + .request_permissions + .remove(&("thread-b".to_string(), "perm-1".to_string())), + Some(RequestId::Integer(6)) + ); + assert_eq!( + pending.pop_request_user_input_request_id("thread-b", "turn-1"), + Some(RequestId::Integer(8)) + ); + assert_eq!( + pending + .dynamic_tool_calls + .remove(&("thread-b".to_string(), "tool-1".to_string())), + Some(RequestId::Integer(10)) + ); + assert_eq!( + pending.pop_mcp_elicitation_request_id( + "thread-b", + "server", + &codex_protocol::mcp::RequestId::Integer(12), + ), + Some(RequestId::Integer(13)) + ); + assert_eq!( + pending + .exec_approvals + .remove(&("thread-a".to_string(), "exec-1".to_string())), + Some(PendingExecApprovalRequest::V2(RequestId::Integer(1))) + ); + assert_eq!( + pending + .patch_approvals + .remove(&("thread-a".to_string(), "patch-1".to_string())), + Some(PendingPatchApprovalRequest::V2(RequestId::Integer(3))) + ); + assert_eq!( + pending + .request_permissions + .remove(&("thread-a".to_string(), "perm-1".to_string())), + Some(RequestId::Integer(5)) + ); + assert_eq!( + pending.pop_request_user_input_request_id("thread-a", "turn-1"), + Some(RequestId::Integer(7)) + ); + assert_eq!( + pending + .dynamic_tool_calls + .remove(&("thread-a".to_string(), "tool-1".to_string())), + Some(RequestId::Integer(9)) + ); + assert_eq!( + pending.pop_mcp_elicitation_request_id( + "thread-a", + "server", + &codex_protocol::mcp::RequestId::Integer(12), + ), + Some(RequestId::Integer(11)) + ); + } + + #[test] + fn lagged_event_warning_message_is_explicit() { + assert_eq!( + lagged_event_warning_message(7), + "in-process app-server event stream lagged; dropped 7 events".to_string() + ); + } + + fn session_configured_event() -> SessionConfiguredEvent { + SessionConfiguredEvent { + session_id: ThreadId::from_string("019cbf93-9ff5-7ac0-ac93-c8a36f0c98d3") + .expect("valid thread id"), + forked_from_id: None, + thread_name: Some("thread".to_string()), + model: "gpt-5".to_string(), + model_provider_id: "openai".to_string(), + service_tier: None, + approval_policy: codex_protocol::protocol::AskForApproval::Never, + sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, + cwd: std::env::temp_dir(), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: Some(PathBuf::from("/tmp/thread.jsonl")), + } + } + + #[test] + fn merge_session_configured_update_enriches_missing_metadata() { + let current = session_configured_event(); + let mut update = session_configured_event(); + update.forked_from_id = Some(ThreadId::new()); + update.history_log_id = 41; + update.history_entry_count = 9; + + let merged = merge_session_configured_update(¤t, update) + .expect("update should enrich session metadata"); + + assert_eq!(merged.history_log_id, 41); + assert_eq!(merged.history_entry_count, 9); + assert!(merged.forked_from_id.is_some()); + assert_eq!(merged.rollout_path, current.rollout_path); + } + + #[test] + fn merge_session_configured_update_ignores_identical_payload() { + let current = session_configured_event(); + + let merged = merge_session_configured_update(¤t, session_configured_event()); + + assert_eq!(merged.is_none(), true); + } + + #[tokio::test] + async fn spawn_agent_bootstrap_preserves_local_history_metadata() { + let codex_home = TempDir::new().expect("create temp dir"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("config"); + append_history_entry_local(&config, &ThreadId::new(), "first".to_string()) + .await + .expect("append first history entry"); + append_history_entry_local(&config, &ThreadId::new(), "second".to_string()) + .await + .expect("append second history entry"); + + let (tx, mut rx) = unbounded_channel(); + let app_event_tx = AppEventSender::new(tx); + let thread_manager = Arc::new( + codex_core::test_support::thread_manager_with_models_provider_and_home( + codex_core::CodexAuth::from_api_key("test"), + config.model_provider.clone(), + config.codex_home.clone(), + ), + ); + + let (codex_op_tx, _thread_scoped_op_tx) = spawn_agent( + config.clone(), + app_event_tx, + thread_manager, + InProcessAgentContext { + arg0_paths: codex_arg0::Arg0DispatchPaths::default(), + cli_kv_overrides: Vec::new(), + cloud_requirements: CloudRequirementsLoader::default(), + }, + ); + + let maybe_event = timeout(Duration::from_secs(10), rx.recv()) + .await + .expect("timed out waiting for bootstrap app event"); + let event = maybe_event.expect("expected bootstrap app event"); + let AppEvent::CodexEvent(event) = event else { + panic!("expected bootstrap codex event"); + }; + let EventMsg::SessionConfigured(session) = event.msg else { + panic!("expected SessionConfigured"); + }; + assert_ne!(session.history_log_id, 0); + assert_eq!(session.history_entry_count, 2); + + drop(codex_op_tx); + } + + #[tokio::test] + async fn thread_start_params_from_config_preserves_effective_overrides() { + let mut config = test_config().await; + config.model = Some("gpt-5-mini".to_string()); + config.model_provider_id = "test-provider".to_string(); + config.service_tier = Some(codex_protocol::config_types::ServiceTier::Flex); + config.base_instructions = Some("base instructions".to_string()); + config.developer_instructions = Some("developer instructions".to_string()); + config.personality = Some(codex_protocol::config_types::Personality::Friendly); + config + .permissions + .approval_policy + .set(codex_protocol::protocol::AskForApproval::OnRequest) + .expect("set approval policy"); + config + .permissions + .sandbox_policy + .set(codex_protocol::protocol::SandboxPolicy::new_workspace_write_policy()) + .expect("set sandbox policy"); + + let params = thread_start_params_from_config(&config); + + assert_eq!( + params, + ThreadStartParams { + model: Some("gpt-5-mini".to_string()), + model_provider: Some("test-provider".to_string()), + service_tier: Some(Some(codex_protocol::config_types::ServiceTier::Flex)), + cwd: Some(config.cwd.to_string_lossy().into_owned()), + approval_policy: Some(codex_app_server_protocol::AskForApproval::OnRequest), + sandbox: Some(codex_app_server_protocol::SandboxMode::WorkspaceWrite), + config: None, + service_name: None, + base_instructions: Some("base instructions".to_string()), + developer_instructions: Some("developer instructions".to_string()), + personality: Some(codex_protocol::config_types::Personality::Friendly), + ephemeral: None, + dynamic_tools: None, + mock_experimental_field: None, + experimental_raw_events: false, + persist_extended_history: false, + } + ); + } + + #[test] + fn legacy_notification_decodes_prefixed_warning_with_direct_payload() { + let notification = JSONRPCNotification { + method: "codex/event/warning".to_string(), + params: Some(serde_json::json!({ + "message": "heads up", + })), + }; + + let event = legacy_notification_to_event(notification).expect("decode warning"); + let EventMsg::Warning(warning) = event.msg else { + panic!("expected warning event"); + }; + assert_eq!(warning.message, "heads up".to_string()); + } + + #[test] + fn legacy_notification_decodes_prefixed_warning_with_event_wrapper_payload() { + let thread_id = ThreadId::new(); + let notification = JSONRPCNotification { + method: "codex/event/warning".to_string(), + params: Some(serde_json::json!({ + "conversationId": thread_id.to_string(), + "id": "submission-1", + "msg": { + "message": "wrapped warning", + "type": "warning", + }, + })), + }; + + let event = legacy_notification_to_event(notification).expect("decode wrapped warning"); + assert_eq!(event.id, "submission-1"); + let EventMsg::Warning(warning) = event.msg else { + panic!("expected warning event"); + }; + assert_eq!(warning.message, "wrapped warning".to_string()); + } + + #[test] + fn decode_legacy_notification_preserves_conversation_id() { + let thread_id = ThreadId::new(); + let decoded = decode_legacy_notification(JSONRPCNotification { + method: "codex/event/warning".to_string(), + params: Some(serde_json::json!({ + "conversationId": thread_id.to_string(), + "msg": { + "message": "wrapped warning", + "type": "warning", + }, + })), + }) + .expect("decode wrapped warning"); + + assert_eq!(decoded.conversation_id, Some(thread_id)); + let EventMsg::Warning(warning) = decoded.event.msg else { + panic!("expected warning event"); + }; + assert_eq!(warning.message, "wrapped warning".to_string()); + } + + #[test] + fn decode_legacy_notification_defaults_missing_event_id() { + let decoded = decode_legacy_notification(JSONRPCNotification { + method: "codex/event/warning".to_string(), + params: Some(serde_json::json!({ + "msg": { + "message": "wrapped warning", + "type": "warning", + }, + })), + }) + .expect("decode wrapped warning"); + + assert!(decoded.event.id.is_empty()); + let EventMsg::Warning(warning) = decoded.event.msg else { + panic!("expected warning event"); + }; + assert_eq!(warning.message, "wrapped warning".to_string()); + } + + #[test] + fn legacy_notification_decodes_prefixed_mcp_startup_complete() { + let notification = JSONRPCNotification { + method: "codex/event/mcp_startup_complete".to_string(), + params: Some(serde_json::json!({ + "ready": ["server-a"], + "failed": [], + "cancelled": [], + })), + }; + + let event = + legacy_notification_to_event(notification).expect("decode mcp startup complete"); + let EventMsg::McpStartupComplete(payload) = event.msg else { + panic!("expected mcp startup complete event"); + }; + assert_eq!(payload.ready, vec!["server-a".to_string()]); + assert!(payload.failed.is_empty()); + assert!(payload.cancelled.is_empty()); + } + + #[tokio::test] + async fn in_process_start_args_opt_outs_cover_typed_interactive_requests() { + let config = test_config().await; + let args = in_process_start_args( + &config, + test_thread_manager(&config), + codex_arg0::Arg0DispatchPaths::default(), + Vec::new(), + CloudRequirementsLoader::default(), + ); + + assert_eq!( + args.opt_out_notification_methods, + in_process_typed_event_legacy_opt_outs() + ); + } + + #[test] + fn typed_interactive_server_requests_and_legacy_opt_outs_stay_in_sync() { + let requests = [ + ServerRequest::CommandExecutionRequestApproval { + request_id: RequestId::Integer(1), + params: CommandExecutionRequestApprovalParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "item-1".to_string(), + approval_id: Some("approval-1".to_string()), + reason: Some("needs approval".to_string()), + network_approval_context: None, + command: Some("echo hello".to_string()), + cwd: Some(PathBuf::from("/tmp/project")), + command_actions: None, + additional_permissions: None, + skill_metadata: None, + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + available_decisions: None, + }, + }, + ServerRequest::ExecCommandApproval { + request_id: RequestId::Integer(2), + params: ExecCommandApprovalParams { + conversation_id: ThreadId::new(), + call_id: "call-1".to_string(), + approval_id: Some("approval-legacy-1".to_string()), + command: vec!["echo".to_string(), "hello".to_string()], + cwd: PathBuf::from("/tmp/project"), + reason: Some("legacy approval".to_string()), + parsed_cmd: Vec::new(), + }, + }, + ServerRequest::FileChangeRequestApproval { + request_id: RequestId::Integer(3), + params: FileChangeRequestApprovalParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "patch-1".to_string(), + reason: Some("write access".to_string()), + grant_root: None, + }, + }, + ServerRequest::ApplyPatchApproval { + request_id: RequestId::Integer(4), + params: codex_app_server_protocol::ApplyPatchApprovalParams { + conversation_id: ThreadId::new(), + call_id: "patch-legacy-1".to_string(), + file_changes: HashMap::new(), + reason: Some("legacy patch".to_string()), + grant_root: None, + }, + }, + ServerRequest::ToolRequestUserInput { + request_id: RequestId::Integer(5), + params: ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "input-1".to_string(), + questions: vec![ToolRequestUserInputQuestion { + id: "q1".to_string(), + header: "Header".to_string(), + question: "Question?".to_string(), + is_other: false, + is_secret: false, + options: Some(vec![ToolRequestUserInputOption { + label: "Option".to_string(), + description: "Description".to_string(), + }]), + }], + }, + }, + ServerRequest::McpServerElicitationRequest { + request_id: RequestId::Integer(6), + params: McpServerElicitationRequestParams { + thread_id: "thread-1".to_string(), + turn_id: Some("turn-1".to_string()), + server_name: "server-1".to_string(), + request: McpServerElicitationRequest::Form { + meta: None, + message: "Allow this request?".to_string(), + requested_schema: McpElicitationSchema { + schema_uri: None, + type_: McpElicitationObjectType::Object, + properties: Default::default(), + required: None, + }, + }, + }, + }, + ServerRequest::DynamicToolCall { + request_id: RequestId::Integer(7), + params: DynamicToolCallParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + call_id: "dynamic-1".to_string(), + tool: "tool".to_string(), + arguments: serde_json::json!({ "arg": 1 }), + }, + }, + ServerRequest::PermissionsRequestApproval { + request_id: RequestId::Integer(8), + params: PermissionsRequestApprovalParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "permissions-1".to_string(), + reason: Some("Select a root".to_string()), + permissions: codex_app_server_protocol::AdditionalPermissionProfile { + network: None, + file_system: None, + macos: None, + }, + }, + }, + ServerRequest::ChatgptAuthTokensRefresh { + request_id: RequestId::Integer(9), + params: ChatgptAuthTokensRefreshParams { + reason: ChatgptAuthTokensRefreshReason::Unauthorized, + previous_account_id: None, + }, + }, + ]; + + let mut mapped_methods = requests + .iter() + .filter_map(in_process_typed_interactive_request) + .map(InProcessTypedInteractiveRequest::legacy_notification_method) + .collect::>(); + mapped_methods.sort_unstable(); + mapped_methods.dedup(); + + let mut expected_methods = InProcessTypedInteractiveRequest::ALL + .into_iter() + .map(InProcessTypedInteractiveRequest::legacy_notification_method) + .collect::>(); + expected_methods.sort_unstable(); + + assert_eq!(mapped_methods, expected_methods); + } + + #[tokio::test] + async fn realtime_start_op_routes_to_thread_realtime_start_method() { + assert_realtime_op_reports_expected_method( + Op::RealtimeConversationStart(ConversationStartParams { + prompt: "hello".to_string(), + session_id: None, + }), + "thread/realtime/start", + ) + .await; + } + + #[tokio::test] + async fn realtime_audio_op_routes_to_thread_realtime_append_audio_method() { + assert_realtime_op_reports_expected_method( + Op::RealtimeConversationAudio(ConversationAudioParams { + frame: RealtimeAudioFrame { + data: "aGVsbG8=".to_string(), + sample_rate: 24_000, + num_channels: 1, + samples_per_channel: Some(1), + }, + }), + "thread/realtime/appendAudio", + ) + .await; + } + + #[tokio::test] + async fn realtime_text_op_routes_to_thread_realtime_append_text_method() { + assert_realtime_op_reports_expected_method( + Op::RealtimeConversationText(ConversationTextParams { + text: "hello".to_string(), + }), + "thread/realtime/appendText", + ) + .await; + } + + #[tokio::test] + async fn realtime_close_op_routes_to_thread_realtime_stop_method() { + assert_realtime_op_reports_expected_method( + Op::RealtimeConversationClose, + "thread/realtime/stop", + ) + .await; + } + + #[tokio::test] + async fn list_custom_prompts_emits_response_event_locally() { + let config = test_config().await; + let (should_shutdown, mut rx, client, _thread_manager, _session_id) = + process_single_op(&config, Op::ListCustomPrompts).await; + assert_eq!(should_shutdown, false); + + let event = next_codex_event(&mut rx).await; + let EventMsg::ListCustomPromptsResponse(_) = event.msg else { + panic!("expected ListCustomPromptsResponse"); + }; + + client.shutdown().await.expect("shutdown in-process client"); + } + + #[tokio::test] + async fn add_to_history_and_get_history_entry_work_locally() { + let codex_home = TempDir::new().expect("create temp dir"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("config"); + let session_id = ThreadId::new(); + let thread_id = session_id.to_string(); + let thread_manager = test_thread_manager(&config); + let client = InProcessAppServerClient::start(in_process_start_args( + &config, + Arc::clone(&thread_manager), + codex_arg0::Arg0DispatchPaths::default(), + Vec::new(), + CloudRequirementsLoader::default(), + )) + .await + .expect("in-process app-server client"); + let (tx, mut rx) = unbounded_channel(); + let app_event_tx = AppEventSender::new(tx); + let mut current_turn_ids = HashMap::new(); + let mut request_ids = RequestIdSequencer::new(); + let mut pending_server_requests = PendingServerRequests::default(); + + let should_shutdown = process_in_process_command( + Op::AddToHistory { + text: "hello history".to_string(), + }, + &thread_id, + &thread_id, + None, + &session_id, + &config, + &mut current_turn_ids, + &mut request_ids, + &mut pending_server_requests, + &client, + thread_manager.as_ref(), + &app_event_tx, + ) + .await; + assert_eq!(should_shutdown, false); + + let should_shutdown = process_in_process_command( + Op::GetHistoryEntryRequest { + offset: 0, + log_id: 0, + }, + &thread_id, + &thread_id, + None, + &session_id, + &config, + &mut current_turn_ids, + &mut request_ids, + &mut pending_server_requests, + &client, + thread_manager.as_ref(), + &app_event_tx, + ) + .await; + assert_eq!(should_shutdown, false); + + let event = next_codex_event(&mut rx).await; + let EventMsg::GetHistoryEntryResponse(response) = event.msg else { + panic!("expected GetHistoryEntryResponse"); + }; + let entry = response.entry.expect("expected history entry"); + assert_eq!(response.offset, 0); + assert_eq!(response.log_id, 0); + assert_eq!(entry.conversation_id, thread_id); + assert_eq!(entry.text, "hello history".to_string()); + + client.shutdown().await.expect("shutdown in-process client"); + } + + async fn assert_forwarded_op(config: &Config, op: Op) { + let (should_shutdown, _rx, client, thread_manager, session_id) = + process_single_op(config, op.clone()).await; + assert_eq!(should_shutdown, false); + assert_eq!( + thread_manager.captured_ops_for_testing(), + vec![(session_id, op)] + ); + + client.shutdown().await.expect("shutdown in-process client"); + } + + #[tokio::test] + async fn reload_user_config_is_forwarded_to_thread() { + let config = test_config().await; + assert_forwarded_op(&config, Op::ReloadUserConfig).await; + } + + async fn assert_local_only_warning_for_op(config: &Config, op: Op, expected_message: &str) { + let (should_shutdown, mut rx, client, _thread_manager, _session_id) = + process_single_op(config, op).await; + assert_eq!(should_shutdown, false); + + let event = next_codex_event(&mut rx).await; + let warning = warning_from_event(event); + assert_eq!(warning.message, expected_message.to_string()); + + client.shutdown().await.expect("shutdown in-process client"); + } + + #[tokio::test] + async fn review_op_sets_current_turn_id_for_follow_up_interrupts() { + let config = test_config().await; + let thread_manager = test_thread_manager(&config); + let client = InProcessAppServerClient::start(in_process_start_args( + &config, + Arc::clone(&thread_manager), + codex_arg0::Arg0DispatchPaths::default(), + Vec::new(), + CloudRequirementsLoader::default(), + )) + .await + .expect("in-process app-server client"); + let (tx, mut rx) = unbounded_channel(); + let app_event_tx = AppEventSender::new(tx); + let mut current_turn_ids = HashMap::new(); + let mut request_ids = RequestIdSequencer::new(); + let mut pending_server_requests = PendingServerRequests::default(); + + let thread_start = send_request_with_response::( + &client, + ClientRequest::ThreadStart { + request_id: request_ids.next(), + params: ThreadStartParams::default(), + }, + "thread/start", + ) + .await + .expect("thread/start"); + let thread_id = thread_start.thread.id; + let session_id = ThreadId::from_string(&thread_id).expect("valid thread id"); + + let should_shutdown = process_in_process_command( + Op::Review { + review_request: codex_protocol::protocol::ReviewRequest { + target: CoreReviewTarget::Custom { + instructions: "check current changes".to_string(), + }, + user_facing_hint: None, + }, + }, + &thread_id, + &thread_id, + None, + &session_id, + &config, + &mut current_turn_ids, + &mut request_ids, + &mut pending_server_requests, + &client, + thread_manager.as_ref(), + &app_event_tx, + ) + .await; + assert_eq!(should_shutdown, false); + let turn_id = current_turn_ids + .get(&thread_id) + .expect("review/start should set the active turn id"); + assert_eq!(turn_id.is_empty(), false); + + if let Ok(Some(event)) = timeout(Duration::from_millis(200), rx.recv()).await { + panic!("did not expect an app event after review/start: {event:?}"); + } + + let should_shutdown = process_in_process_command( + Op::Interrupt, + &thread_id, + &thread_id, + None, + &session_id, + &config, + &mut current_turn_ids, + &mut request_ids, + &mut pending_server_requests, + &client, + thread_manager.as_ref(), + &app_event_tx, + ) + .await; + assert_eq!(should_shutdown, false); + if let Ok(Some(event)) = timeout(Duration::from_millis(200), rx.recv()).await { + panic!("did not expect an app event after successful turn/interrupt: {event:?}"); + } + + client.shutdown().await.expect("shutdown in-process client"); + } + + #[tokio::test] + async fn child_shutdown_does_not_request_in_process_loop_exit() { + let config = test_config().await; + let thread_manager = test_thread_manager(&config); + let client = InProcessAppServerClient::start(in_process_start_args( + &config, + Arc::clone(&thread_manager), + codex_arg0::Arg0DispatchPaths::default(), + Vec::new(), + CloudRequirementsLoader::default(), + )) + .await + .expect("in-process app-server client"); + let (tx, _rx) = unbounded_channel(); + let app_event_tx = AppEventSender::new(tx); + let mut current_turn_ids = HashMap::from([ + ("primary-thread".to_string(), "primary-turn".to_string()), + ("child-thread".to_string(), "child-turn".to_string()), + ]); + let mut request_ids = RequestIdSequencer::new(); + let mut pending_server_requests = PendingServerRequests::default(); + pending_server_requests.register_request_user_input( + "child-thread".to_string(), + "child-turn".to_string(), + RequestId::Integer(5), + ); + let session_id = ThreadId::new(); + + let should_shutdown = process_in_process_command( + Op::Shutdown, + "child-thread", + "primary-thread", + None, + &session_id, + &config, + &mut current_turn_ids, + &mut request_ids, + &mut pending_server_requests, + &client, + thread_manager.as_ref(), + &app_event_tx, + ) + .await; + + assert_eq!(should_shutdown, false); + assert_eq!( + current_turn_ids, + HashMap::from([("primary-thread".to_string(), "primary-turn".to_string())]) + ); + assert!(pending_server_requests.request_user_input.is_empty()); + + client.shutdown().await.expect("shutdown in-process client"); + } + + #[tokio::test] + async fn primary_thread_closed_notification_requests_in_process_loop_shutdown() { + let config = test_config().await; + let thread_manager = test_thread_manager(&config); + let client = InProcessAppServerClient::start(in_process_start_args( + &config, + Arc::clone(&thread_manager), + codex_arg0::Arg0DispatchPaths::default(), + Vec::new(), + CloudRequirementsLoader::default(), + )) + .await + .expect("in-process app-server client"); + let mut request_ids = RequestIdSequencer::new(); + let thread_start = send_request_with_response::( + &client, + ClientRequest::ThreadStart { + request_id: request_ids.next(), + params: ThreadStartParams::default(), + }, + "thread/start", + ) + .await + .expect("thread/start"); + let session_configured = + session_configured_from_thread_start_response(&config, thread_start) + .await + .expect("session configured"); + let thread_id = session_configured.session_id.to_string(); + let (tx, mut rx) = unbounded_channel(); + let app_event_tx = AppEventSender::new(tx); + let (codex_op_tx, codex_op_rx) = unbounded_channel(); + let (thread_scoped_op_tx, thread_scoped_op_rx) = unbounded_channel(); + + let run_loop = tokio::spawn(run_in_process_agent_loop( + codex_op_rx, + thread_scoped_op_rx, + client, + Arc::clone(&thread_manager), + config, + thread_id, + session_configured, + app_event_tx, + RequestIdSequencer::new(), + HashMap::new(), + )); + + let report = thread_manager + .shutdown_all_threads_bounded(Duration::from_secs(10)) + .await; + assert!(report.submit_failed.is_empty()); + assert!(report.timed_out.is_empty()); + + let shutdown_complete = timeout(Duration::from_secs(2), async { + loop { + let event = next_codex_event(&mut rx).await; + if matches!(event.msg, EventMsg::ShutdownComplete) { + break event; + } + } + }) + .await + .expect("timed out waiting for shutdown complete event"); + assert!(matches!(shutdown_complete.msg, EventMsg::ShutdownComplete)); + + timeout(Duration::from_secs(2), run_loop) + .await + .expect("timed out waiting for run loop to exit") + .expect("run loop task should not panic"); + + drop(codex_op_tx); + drop(thread_scoped_op_tx); + } + + #[tokio::test] + async fn interrupt_uses_active_turn_for_target_thread_only() { + let config = test_config().await; + let session_id = ThreadId::new(); + let thread_manager = test_thread_manager(&config); + let client = InProcessAppServerClient::start(in_process_start_args( + &config, + Arc::clone(&thread_manager), + codex_arg0::Arg0DispatchPaths::default(), + Vec::new(), + CloudRequirementsLoader::default(), + )) + .await + .expect("in-process app-server client"); + let (tx, mut rx) = unbounded_channel(); + let app_event_tx = AppEventSender::new(tx); + let mut current_turn_ids = + HashMap::from([("child-thread".to_string(), "child-turn".to_string())]); + let mut request_ids = RequestIdSequencer::new(); + let mut pending_server_requests = PendingServerRequests::default(); + + let should_shutdown = process_in_process_command( + Op::Interrupt, + "primary-thread", + "primary-thread", + None, + &session_id, + &config, + &mut current_turn_ids, + &mut request_ids, + &mut pending_server_requests, + &client, + thread_manager.as_ref(), + &app_event_tx, + ) + .await; + + assert_eq!(should_shutdown, false); + let event = next_codex_event(&mut rx).await; + let warning = warning_from_event(event); + assert_eq!( + warning.message, + "turn/interrupt skipped because there is no active turn".to_string() + ); + + client.shutdown().await.expect("shutdown in-process client"); + } + + #[tokio::test] + async fn undo_still_emits_explicit_local_only_warning() { + let config = test_config().await; + assert_local_only_warning_for_op( + &config, + Op::Undo, + "Undo is temporarily unavailable in in-process local-only mode", + ) + .await; + } + + #[tokio::test] + async fn override_turn_context_is_forwarded_to_thread() { + let config = test_config().await; + assert_forwarded_op( + &config, + Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + windows_sandbox_level: None, + model: None, + effort: None, + summary: None, + service_tier: None, + collaboration_mode: None, + personality: None, + }, + ) + .await; + } + + #[tokio::test] + async fn legacy_core_ops_are_forwarded_to_thread() { + let config = test_config().await; + let forwarded_ops = vec![ + Op::DropMemories, + Op::UpdateMemories, + Op::RunUserShellCommand { + command: "echo hello".to_string(), + }, + Op::ListMcpTools, + ]; + + for op in forwarded_ops { + assert_forwarded_op(&config, op).await; + } + } + + #[tokio::test] + async fn resolve_elicitation_without_pending_request_warns() { + let config = test_config().await; + let (should_shutdown, mut rx, client, _thread_manager, _session_id) = process_single_op( + &config, + Op::ResolveElicitation { + server_name: "test-server".to_string(), + request_id: codex_protocol::mcp::RequestId::Integer(1), + decision: codex_protocol::approvals::ElicitationAction::Cancel, + content: None, + meta: None, + }, + ) + .await; + assert_eq!(should_shutdown, false); + + let event = next_codex_event(&mut rx).await; + let warning = warning_from_event(event); + assert_eq!( + warning.message, + "mcp elicitation response ignored because `test-server` request `1` was not pending" + .to_string() + ); + + client.shutdown().await.expect("shutdown in-process client"); + } + + #[tokio::test] + async fn local_external_chatgpt_refresh_uses_base_refresher_over_in_process_override() { + let codex_home = TempDir::new().expect("create temp dir"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("config"); + let stale_access_token = fake_external_access_token("pro"); + login_with_chatgpt_auth_tokens( + &config.codex_home, + &stale_access_token, + "workspace-1", + Some("pro"), + ) + .expect("write external auth token"); + + let auth_manager = AuthManager::shared( + config.codex_home.clone(), + false, + config.cli_auth_credentials_store_mode, + ); + auth_manager.reload(); + let base_refresher = Arc::new(RecordingExternalAuthRefresher { + refreshed: ExternalAuthTokens { + access_token: fake_external_access_token("enterprise"), + chatgpt_account_id: "workspace-1".to_string(), + chatgpt_plan_type: Some("enterprise".to_string()), + }, + contexts: Mutex::new(Vec::new()), + }); + auth_manager.set_external_auth_refresher(base_refresher.clone()); + let _override_guard = auth_manager.push_external_auth_override( + Arc::new(FailingExternalAuthRefresher), + auth_manager.forced_chatgpt_workspace_id(), + ); + + let response = local_external_chatgpt_tokens(Arc::clone(&auth_manager)) + .await + .expect("local token refresh response"); + assert_eq!( + response.access_token, + fake_external_access_token("enterprise") + ); + assert_eq!(response.chatgpt_account_id, "workspace-1".to_string()); + assert_eq!(response.chatgpt_plan_type, Some("enterprise".to_string())); + assert_eq!( + base_refresher + .contexts + .lock() + .expect("contexts mutex") + .as_slice(), + &[ExternalAuthRefreshContext { + reason: codex_core::auth::ExternalAuthRefreshReason::Unauthorized, + previous_account_id: Some("workspace-1".to_string()), + }] + ); + } + + #[tokio::test] + async fn local_external_chatgpt_refresh_fails_without_external_auth() { + let codex_home = TempDir::new().expect("create temp dir"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("config"); + let auth_manager = AuthManager::shared( + config.codex_home.clone(), + false, + config.cli_auth_credentials_store_mode, + ); + let error = local_external_chatgpt_tokens(auth_manager) + .await + .expect_err("expected local refresh error"); + assert!( + error.contains("no cached auth available") + || error.contains("external ChatGPT token auth is not active"), + "unexpected error: {error}" + ); + } + + #[test] + fn refreshed_chatgpt_account_mismatch_is_rejected() { + let error = validate_refreshed_chatgpt_account(Some("workspace-1"), "workspace-2") + .expect_err("expected account mismatch to fail"); + assert_eq!( + error, + "local auth refresh account mismatch: expected `workspace-1`, got `workspace-2`" + .to_string() + ); + } } diff --git a/codex-rs/tui/src/chatwidget/interrupts.rs b/codex-rs/tui/src/chatwidget/interrupts.rs index 0a3fc800168..6bf2e4e0684 100644 --- a/codex-rs/tui/src/chatwidget/interrupts.rs +++ b/codex-rs/tui/src/chatwidget/interrupts.rs @@ -1,5 +1,6 @@ use std::collections::VecDeque; +use codex_protocol::ThreadId; use codex_protocol::approvals::ElicitationRequestEvent; use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; use codex_protocol::protocol::ExecApprovalRequestEvent; @@ -9,17 +10,29 @@ use codex_protocol::protocol::McpToolCallBeginEvent; use codex_protocol::protocol::McpToolCallEndEvent; use codex_protocol::protocol::PatchApplyEndEvent; use codex_protocol::request_permissions::RequestPermissionsEvent; -use codex_protocol::request_user_input::RequestUserInputEvent; use super::ChatWidget; +use crate::bottom_pane::ThreadUserInputRequest; #[derive(Debug)] pub(crate) enum QueuedInterrupt { - ExecApproval(ExecApprovalRequestEvent), - ApplyPatchApproval(ApplyPatchApprovalRequestEvent), - Elicitation(ElicitationRequestEvent), - RequestPermissions(RequestPermissionsEvent), - RequestUserInput(RequestUserInputEvent), + ExecApproval { + thread_id: ThreadId, + event: ExecApprovalRequestEvent, + }, + ApplyPatchApproval { + thread_id: ThreadId, + event: ApplyPatchApprovalRequestEvent, + }, + Elicitation { + thread_id: ThreadId, + event: ElicitationRequestEvent, + }, + RequestPermissions { + thread_id: ThreadId, + event: RequestPermissionsEvent, + }, + RequestUserInput(ThreadUserInputRequest), ExecBegin(ExecCommandBeginEvent), ExecEnd(ExecCommandEndEvent), McpBegin(McpToolCallBeginEvent), @@ -44,26 +57,45 @@ impl InterruptManager { self.queue.is_empty() } - pub(crate) fn push_exec_approval(&mut self, ev: ExecApprovalRequestEvent) { - self.queue.push_back(QueuedInterrupt::ExecApproval(ev)); + pub(crate) fn push_exec_approval(&mut self, thread_id: ThreadId, ev: ExecApprovalRequestEvent) { + self.queue.push_back(QueuedInterrupt::ExecApproval { + thread_id, + event: ev, + }); } - pub(crate) fn push_apply_patch_approval(&mut self, ev: ApplyPatchApprovalRequestEvent) { - self.queue - .push_back(QueuedInterrupt::ApplyPatchApproval(ev)); + pub(crate) fn push_apply_patch_approval( + &mut self, + thread_id: ThreadId, + ev: ApplyPatchApprovalRequestEvent, + ) { + self.queue.push_back(QueuedInterrupt::ApplyPatchApproval { + thread_id, + event: ev, + }); } - pub(crate) fn push_elicitation(&mut self, ev: ElicitationRequestEvent) { - self.queue.push_back(QueuedInterrupt::Elicitation(ev)); + pub(crate) fn push_elicitation(&mut self, thread_id: ThreadId, ev: ElicitationRequestEvent) { + self.queue.push_back(QueuedInterrupt::Elicitation { + thread_id, + event: ev, + }); } - pub(crate) fn push_request_permissions(&mut self, ev: RequestPermissionsEvent) { - self.queue - .push_back(QueuedInterrupt::RequestPermissions(ev)); + pub(crate) fn push_request_permissions( + &mut self, + thread_id: ThreadId, + ev: RequestPermissionsEvent, + ) { + self.queue.push_back(QueuedInterrupt::RequestPermissions { + thread_id, + event: ev, + }); } - pub(crate) fn push_user_input(&mut self, ev: RequestUserInputEvent) { - self.queue.push_back(QueuedInterrupt::RequestUserInput(ev)); + pub(crate) fn push_user_input(&mut self, request: ThreadUserInputRequest) { + self.queue + .push_back(QueuedInterrupt::RequestUserInput(request)); } pub(crate) fn push_exec_begin(&mut self, ev: ExecCommandBeginEvent) { @@ -89,11 +121,21 @@ impl InterruptManager { pub(crate) fn flush_all(&mut self, chat: &mut ChatWidget) { while let Some(q) = self.queue.pop_front() { match q { - QueuedInterrupt::ExecApproval(ev) => chat.handle_exec_approval_now(ev), - QueuedInterrupt::ApplyPatchApproval(ev) => chat.handle_apply_patch_approval_now(ev), - QueuedInterrupt::Elicitation(ev) => chat.handle_elicitation_request_now(ev), - QueuedInterrupt::RequestPermissions(ev) => chat.handle_request_permissions_now(ev), - QueuedInterrupt::RequestUserInput(ev) => chat.handle_request_user_input_now(ev), + QueuedInterrupt::ExecApproval { thread_id, event } => { + chat.handle_exec_approval_for_thread(thread_id, event) + } + QueuedInterrupt::ApplyPatchApproval { thread_id, event } => { + chat.handle_apply_patch_approval_for_thread(thread_id, event) + } + QueuedInterrupt::Elicitation { thread_id, event } => { + chat.handle_elicitation_request_for_thread(thread_id, event) + } + QueuedInterrupt::RequestPermissions { thread_id, event } => { + chat.handle_request_permissions_for_thread(thread_id, event) + } + QueuedInterrupt::RequestUserInput(request) => { + chat.handle_request_user_input_now(request); + } QueuedInterrupt::ExecBegin(ev) => chat.handle_exec_begin_now(ev), QueuedInterrupt::ExecEnd(ev) => chat.handle_exec_end_now(ev), QueuedInterrupt::McpBegin(ev) => chat.handle_mcp_begin_now(ev), diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index e7a0ac1442a..62587628dc0 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -14,9 +14,11 @@ use crate::bottom_pane::FeedbackAudience; use crate::bottom_pane::LocalImageAttachment; use crate::bottom_pane::MentionBinding; use crate::history_cell::UserHistoryCell; +use crate::streaming::controller::StreamController; use crate::test_backend::VT100Backend; use crate::tui::FrameRequester; use assert_matches::assert_matches; +use codex_arg0::Arg0DispatchPaths; use codex_core::CodexAuth; use codex_core::config::Config; use codex_core::config::ConfigBuilder; @@ -25,6 +27,7 @@ use codex_core::config::ConstraintError; use codex_core::config::types::Notifications; #[cfg(target_os = "windows")] use codex_core::config::types::WindowsSandboxModeToml; +use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::RequirementSource; use codex_core::features::FEATURES; use codex_core::features::Feature; @@ -116,6 +119,7 @@ use pretty_assertions::assert_eq; #[cfg(target_os = "windows")] use serial_test::serial; use std::collections::BTreeMap; +use std::collections::HashMap; use std::collections::HashSet; use std::path::PathBuf; use tempfile::NamedTempFile; @@ -1743,12 +1747,92 @@ async fn helpers_are_available_and_do_not_panic() { startup_tooltip_override: None, status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), session_telemetry, + in_process_context: InProcessAgentContext { + arg0_paths: Arg0DispatchPaths::default(), + cli_kv_overrides: Vec::new(), + cloud_requirements: CloudRequirementsLoader::default(), + }, }; let mut w = ChatWidget::new(init, thread_manager); // Basic construction sanity. let _ = &mut w; } +#[tokio::test] +async fn new_from_existing_submits_ops_to_the_provided_thread() { + let codex_home = tempdir().expect("tempdir"); + let cfg = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("config"); + let resolved_model = codex_core::test_support::get_model_offline(cfg.model.as_deref()); + let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str()); + let auth_manager = codex_core::test_support::auth_manager_from_auth_with_home( + CodexAuth::from_api_key("test"), + cfg.codex_home.clone(), + ); + let thread_manager = Arc::new( + codex_core::test_support::thread_manager_with_models_provider_and_home( + CodexAuth::from_api_key("test"), + cfg.model_provider.clone(), + cfg.codex_home.clone(), + ), + ); + let existing = thread_manager + .start_thread(cfg.clone()) + .await + .expect("start thread"); + let init = ChatWidgetInit { + config: cfg, + frame_requester: FrameRequester::test_dummy(), + app_event_tx: AppEventSender::new(unbounded_channel::().0), + initial_user_message: None, + enhanced_keys_supported: false, + auth_manager, + models_manager: thread_manager.get_models_manager(), + feedback: codex_feedback::CodexFeedback::new(), + is_first_run: false, + feedback_audience: FeedbackAudience::External, + model: Some(resolved_model), + startup_tooltip_override: None, + status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), + session_telemetry, + in_process_context: InProcessAgentContext { + arg0_paths: Arg0DispatchPaths::default(), + cli_kv_overrides: Vec::new(), + cloud_requirements: CloudRequirementsLoader::default(), + }, + }; + + let mut chat = + ChatWidget::new_from_existing(init, existing.thread.clone(), existing.session_configured); + assert!(chat.submit_op(Op::Shutdown)); + + let shutdown_result = tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let event = existing.thread.next_event().await.expect("thread event"); + if matches!(event.msg, EventMsg::ShutdownComplete) { + break; + } + } + }) + .await; + + if shutdown_result.is_err() { + existing + .thread + .submit(Op::Shutdown) + .await + .expect("cleanup shutdown"); + } + + assert!( + shutdown_result.is_ok(), + "expected reopened widget to forward shutdown to the provided thread" + ); +} + fn test_session_telemetry(config: &Config, model: &str) -> SessionTelemetry { let model_info = codex_core::test_support::construct_model_info_offline(model, config); SessionTelemetry::new( @@ -1819,6 +1903,8 @@ async fn make_chatwidget_manual( let mut widget = ChatWidget { app_event_tx, codex_op_tx: op_tx, + thread_scoped_op_tx: None, + app_server_thread_id: None, bottom_pane: bottom, active_cell: None, active_cell_revision: 0, @@ -1864,6 +1950,7 @@ async fn make_chatwidget_manual( pending_status_indicator_restore: false, suppress_queue_autosend: false, thread_id: None, + current_turn_id: None, thread_name: None, forked_from: None, frame_requester: FrameRequester::test_dummy(), @@ -1906,6 +1993,60 @@ async fn make_chatwidget_manual( (widget, rx, op_rx) } +#[tokio::test] +async fn submit_op_routes_selected_app_server_thread_via_thread_scoped_sender() { + let (mut chat, _app_event_tx, _app_event_rx, mut op_rx) = + make_chatwidget_manual_with_sender().await; + let (thread_scoped_op_tx, mut thread_scoped_op_rx) = tokio::sync::mpsc::unbounded_channel(); + let thread_id = ThreadId::new(); + + chat.thread_scoped_op_tx = Some(thread_scoped_op_tx); + chat.app_server_thread_id = Some(thread_id); + chat.thread_id = Some(thread_id); + chat.current_turn_id = Some("turn-1".to_string()); + + assert_eq!(chat.submit_op(Op::ListMcpTools), true); + assert_eq!( + thread_scoped_op_rx.try_recv(), + Ok(ThreadScopedOp { + thread_id, + op: Op::ListMcpTools, + interrupt_turn_id: None, + }) + ); + + while let Ok(op) = op_rx.try_recv() { + assert_ne!(op, Op::ListMcpTools); + } +} + +#[tokio::test] +async fn interrupt_routes_selected_app_server_thread_with_current_turn_id() { + let (mut chat, _app_event_tx, _app_event_rx, mut op_rx) = + make_chatwidget_manual_with_sender().await; + let (thread_scoped_op_tx, mut thread_scoped_op_rx) = tokio::sync::mpsc::unbounded_channel(); + let thread_id = ThreadId::new(); + + chat.thread_scoped_op_tx = Some(thread_scoped_op_tx); + chat.app_server_thread_id = Some(thread_id); + chat.thread_id = Some(thread_id); + chat.current_turn_id = Some("turn-1".to_string()); + + assert_eq!(chat.submit_op(Op::Interrupt), true); + assert_eq!( + thread_scoped_op_rx.try_recv(), + Ok(ThreadScopedOp { + thread_id, + op: Op::Interrupt, + interrupt_turn_id: Some("turn-1".to_string()), + }) + ); + + while let Ok(op) = op_rx.try_recv() { + assert_ne!(op, Op::Interrupt); + } +} + // ChatWidget may emit other `Op`s (e.g. history/logging updates) on the same channel; this helper // filters until we see a submission op. fn next_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> Op { @@ -1951,6 +2092,23 @@ pub(crate) fn set_chatgpt_auth(chat: &mut ChatWidget) { )); } +fn next_submit_thread_op( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> (ThreadId, Op) { + loop { + match rx.try_recv() { + Ok(AppEvent::SubmitThreadOp { thread_id, op }) => return (thread_id, op), + Ok(_) => continue, + Err(TryRecvError::Empty) => { + panic!("expected submit-thread op event but queue was empty") + } + Err(TryRecvError::Disconnected) => { + panic!("expected submit-thread op event but channel closed") + } + } + } +} + #[tokio::test] async fn prefetch_rate_limits_is_gated_on_chatgpt_auth_provider() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1993,12 +2151,15 @@ fn drain_insert_history( ) -> Vec>> { let mut out = Vec::new(); while let Ok(ev) = rx.try_recv() { - if let AppEvent::InsertHistoryCell(cell) = ev { - let mut lines = cell.display_lines(80); - if !cell.is_stream_continuation() && !out.is_empty() && !lines.is_empty() { - lines.insert(0, "".into()); + match ev { + AppEvent::InsertHistoryCell(cell) | AppEvent::InsertThreadHistoryCell { cell, .. } => { + let mut lines = cell.display_lines(80); + if !cell.is_stream_continuation() && !out.is_empty() && !lines.is_empty() { + lines.insert(0, "".into()); + } + out.push(lines) } - out.push(lines) + _ => {} } } out @@ -2740,20 +2901,23 @@ async fn user_input_notification_overrides_pending_agent_turn_complete_notificat chat.notify(Notification::AgentTurnComplete { response: "done".to_string(), }); - chat.handle_request_user_input_now(RequestUserInputEvent { - call_id: "call-1".to_string(), - turn_id: "turn-1".to_string(), - questions: vec![RequestUserInputQuestion { - id: "reasoning_scope".to_string(), - header: "Reasoning scope".to_string(), - question: "Which reasoning scope should I use?".to_string(), - is_other: false, - is_secret: false, - options: Some(vec![RequestUserInputQuestionOption { - label: "Plan only".to_string(), - description: "Update only Plan mode.".to_string(), - }]), - }], + chat.handle_request_user_input_now(crate::bottom_pane::ThreadUserInputRequest { + thread_id: ThreadId::new(), + request: RequestUserInputEvent { + call_id: "call-1".to_string(), + turn_id: "turn-1".to_string(), + questions: vec![RequestUserInputQuestion { + id: "reasoning_scope".to_string(), + header: "Reasoning scope".to_string(), + question: "Which reasoning scope should I use?".to_string(), + is_other: false, + is_secret: false, + options: Some(vec![RequestUserInputQuestionOption { + label: "Plan only".to_string(), + description: "Update only Plan mode.".to_string(), + }]), + }], + }, }); assert_matches!( @@ -2770,31 +2934,176 @@ async fn handle_request_user_input_sets_pending_notification() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; chat.config.tui_notifications = Notifications::Custom(vec!["user-input-requested".to_string()]); - chat.handle_request_user_input_now(RequestUserInputEvent { + chat.handle_request_user_input_now(crate::bottom_pane::ThreadUserInputRequest { + thread_id: ThreadId::new(), + request: RequestUserInputEvent { + call_id: "call-1".to_string(), + turn_id: "turn-1".to_string(), + questions: vec![RequestUserInputQuestion { + id: "reasoning_scope".to_string(), + header: "Reasoning scope".to_string(), + question: "Which reasoning scope should I use?".to_string(), + is_other: false, + is_secret: false, + options: Some(vec![RequestUserInputQuestionOption { + label: "Plan only".to_string(), + description: "Update only Plan mode.".to_string(), + }]), + }], + }, + }); + + assert_matches!( + chat.pending_notification, + Some(Notification::UserInputRequested { + question_count: 1, + summary: Some(ref summary), + }) if summary == "Reasoning scope" + ); +} + +#[tokio::test] +async fn deferred_request_user_input_keeps_originating_thread_after_switch() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let original_thread_id = ThreadId::new(); + let switched_thread_id = ThreadId::new(); + chat.thread_id = Some(original_thread_id); + chat.stream_controller = Some(StreamController::new(None, &chat.config.cwd)); + + chat.on_request_user_input(RequestUserInputEvent { call_id: "call-1".to_string(), turn_id: "turn-1".to_string(), questions: vec![RequestUserInputQuestion { - id: "reasoning_scope".to_string(), - header: "Reasoning scope".to_string(), - question: "Which reasoning scope should I use?".to_string(), + id: "q1".to_string(), + header: "Need input".to_string(), + question: "Need input".to_string(), is_other: false, is_secret: false, options: Some(vec![RequestUserInputQuestionOption { - label: "Plan only".to_string(), - description: "Update only Plan mode.".to_string(), + label: "Yes".to_string(), + description: String::new(), }]), }], }); + chat.thread_id = Some(switched_thread_id); + chat.stream_controller = None; + chat.flush_interrupt_queue(); + let _ = chat.bottom_pane.on_ctrl_c(); + + let (thread_id, op) = next_submit_thread_op(&mut rx); + assert_eq!(thread_id, original_thread_id); + assert_eq!(op, Op::Interrupt); +} + +#[tokio::test] +async fn deferred_request_permissions_keeps_originating_thread_after_switch() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let original_thread_id = ThreadId::new(); + let switched_thread_id = ThreadId::new(); + chat.thread_id = Some(original_thread_id); + chat.stream_controller = Some(StreamController::new(None, &chat.config.cwd)); + + chat.on_request_permissions( + codex_protocol::request_permissions::RequestPermissionsEvent { + call_id: "perm-1".to_string(), + turn_id: "turn-1".to_string(), + reason: Some("need access".to_string()), + permissions: codex_protocol::models::PermissionProfile::default(), + }, + ); + + chat.thread_id = Some(switched_thread_id); + chat.stream_controller = None; + chat.flush_interrupt_queue(); + let _ = chat.bottom_pane.on_ctrl_c(); + + let (thread_id, op) = next_submit_thread_op(&mut rx); + assert_eq!(thread_id, original_thread_id); assert_matches!( - chat.pending_notification, - Some(Notification::UserInputRequested { - question_count: 1, - summary: Some(ref summary), - }) if summary == "Reasoning scope" + op, + Op::RequestPermissionsResponse { + id, + response: codex_protocol::request_permissions::RequestPermissionsResponse { + permissions, + .. + }, + } if id == "perm-1" + && permissions == codex_protocol::models::PermissionProfile::default() ); } +#[tokio::test] +async fn approval_handlers_drop_requests_before_session_is_configured() { + let (mut exec_chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + exec_chat.on_exec_approval_request( + "call-1".to_string(), + ExecApprovalRequestEvent { + call_id: "call-1".to_string(), + approval_id: None, + turn_id: "turn-1".to_string(), + command: vec!["echo".to_string(), "hello".to_string()], + cwd: PathBuf::from("/tmp/project"), + reason: Some("needs approval".to_string()), + network_approval_context: None, + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + additional_permissions: None, + skill_metadata: None, + available_decisions: None, + parsed_cmd: Vec::new(), + }, + ); + assert!(exec_chat.bottom_pane.no_modal_or_popup_active()); + assert!(exec_chat.pending_notification.is_none()); + + let (mut patch_chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + patch_chat.on_apply_patch_approval_request( + "patch-1".to_string(), + ApplyPatchApprovalRequestEvent { + call_id: "patch-1".to_string(), + turn_id: "turn-1".to_string(), + reason: Some("review patch".to_string()), + changes: HashMap::from([( + PathBuf::from("src/lib.rs"), + FileChange::Add { + content: "fn main() {}".to_string(), + }, + )]), + grant_root: None, + }, + ); + assert!(patch_chat.bottom_pane.no_modal_or_popup_active()); + assert!(patch_chat.pending_notification.is_none()); + + let (mut elicitation_chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + elicitation_chat.on_elicitation_request(codex_protocol::approvals::ElicitationRequestEvent { + turn_id: Some("turn-1".to_string()), + server_name: "test-server".to_string(), + id: codex_protocol::mcp::RequestId::String("elicitation-1".to_string()), + request: codex_protocol::approvals::ElicitationRequest::Url { + meta: None, + message: "Need input".to_string(), + url: "https://example.com".to_string(), + elicitation_id: "elicitation-1".to_string(), + }, + }); + assert!(elicitation_chat.bottom_pane.no_modal_or_popup_active()); + assert!(elicitation_chat.pending_notification.is_none()); + + let (mut permissions_chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + permissions_chat.on_request_permissions( + codex_protocol::request_permissions::RequestPermissionsEvent { + call_id: "perm-1".to_string(), + turn_id: "turn-1".to_string(), + reason: Some("need access".to_string()), + permissions: codex_protocol::models::PermissionProfile::default(), + }, + ); + assert!(permissions_chat.bottom_pane.no_modal_or_popup_active()); + assert!(permissions_chat.pending_notification.is_none()); +} + #[tokio::test] async fn plan_reasoning_scope_popup_mentions_selected_reasoning() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; @@ -3234,6 +3543,7 @@ async fn plan_implementation_popup_skips_when_rate_limit_prompt_pending() { #[tokio::test] async fn exec_approval_emits_proposed_command_and_decision_history() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); // Trigger an exec approval request with a short, single-line command let ev = ExecApprovalRequestEvent { @@ -3284,6 +3594,7 @@ async fn exec_approval_emits_proposed_command_and_decision_history() { #[tokio::test] async fn exec_approval_uses_approval_id_when_present() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); chat.handle_codex_event(Event { id: "sub-short".into(), @@ -3324,9 +3635,73 @@ async fn exec_approval_uses_approval_id_when_present() { assert!(found, "expected ExecApproval op to be sent"); } +#[tokio::test] +async fn interrupted_turn_dismisses_pending_exec_approval_modal() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); + + chat.handle_codex_event(Event { + id: "sub-approve".into(), + msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { + call_id: "call-approve-exec".into(), + approval_id: Some("call-approve-exec".into()), + turn_id: "turn-1".into(), + command: vec![ + "git".into(), + "fetch".into(), + "upstream".into(), + "main".into(), + ], + cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + reason: Some("need latest upstream".into()), + network_approval_context: None, + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + additional_permissions: None, + skill_metadata: None, + available_decisions: None, + parsed_cmd: vec![], + }), + }); + + assert!( + chat.has_active_view(), + "expected approval modal to be visible" + ); + + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { + turn_id: Some("turn-1".to_string()), + reason: TurnAbortReason::Interrupted, + }), + }); + + assert!( + !chat.has_active_view(), + "expected interrupted turn to dismiss the stale approval modal" + ); + assert!( + op_rx.try_recv().is_err(), + "interrupting should dismiss the approval modal without submitting a stale approval op" + ); + + let _ = drain_insert_history(&mut rx); +} + #[tokio::test] async fn exec_approval_decision_truncates_multiline_and_long_commands() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); // Multiline command: modal should show full command, history records decision only let ev_multi = ExecApprovalRequestEvent { @@ -4583,6 +4958,49 @@ async fn manual_interrupt_restores_pending_steer_mention_bindings_to_composer() assert_no_submit_op(&mut op_rx); } +#[tokio::test] +async fn manual_interrupt_keeps_pending_mcp_elicitation_overlay_visible() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + let thread_id = ThreadId::new(); + chat.thread_id = Some(thread_id); + chat.on_task_started(); + chat.bottom_pane.push_mcp_server_elicitation_request( + crate::bottom_pane::McpServerElicitationFormRequest::from_event( + thread_id, + codex_protocol::approvals::ElicitationRequestEvent { + turn_id: Some("turn-1".to_string()), + server_name: "test-server".to_string(), + id: codex_protocol::mcp::RequestId::String("elicitation-1".to_string()), + request: codex_protocol::approvals::ElicitationRequest::Form { + meta: None, + message: "Need input".to_string(), + requested_schema: serde_json::json!({ + "type": "object", + "properties": { + "answer": { "type": "string", "title": "Answer" } + }, + "required": ["answer"] + }), + }, + }, + ) + .expect("supported MCP elicitation request"), + ); + + assert!( + chat.has_active_view(), + "expected MCP elicitation modal to be visible" + ); + + chat.on_interrupted_turn(TurnAbortReason::Interrupted); + + assert!( + chat.has_active_view(), + "interrupting should preserve MCP elicitation overlays that can outlive the turn" + ); + assert_no_submit_op(&mut op_rx); +} + #[tokio::test] async fn manual_interrupt_restores_pending_steers_before_queued_messages() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; @@ -5600,6 +6018,11 @@ async fn collaboration_modes_defaults_to_code_on_startup() { startup_tooltip_override: None, status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), session_telemetry, + in_process_context: InProcessAgentContext { + arg0_paths: Arg0DispatchPaths::default(), + cli_kv_overrides: Vec::new(), + cloud_requirements: CloudRequirementsLoader::default(), + }, }; let chat = ChatWidget::new(init, thread_manager); @@ -5650,6 +6073,11 @@ async fn experimental_mode_plan_is_ignored_on_startup() { startup_tooltip_override: None, status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), session_telemetry, + in_process_context: InProcessAgentContext { + arg0_paths: Arg0DispatchPaths::default(), + cli_kv_overrides: Vec::new(), + cloud_requirements: CloudRequirementsLoader::default(), + }, }; let chat = ChatWidget::new(init, thread_manager); @@ -8350,6 +8778,7 @@ async fn permissions_full_access_history_cell_emitted_only_after_confirmation() async fn approval_modal_exec_snapshot() -> anyhow::Result<()> { // Build a chat widget with manual channels to avoid spawning the agent. let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); // Ensure policy allows surfacing approvals explicitly (not strictly required for direct event). chat.config .permissions @@ -8415,6 +8844,7 @@ async fn approval_modal_exec_snapshot() -> anyhow::Result<()> { #[tokio::test] async fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); chat.config .permissions .approval_policy @@ -8466,6 +8896,7 @@ async fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> { async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot() -> anyhow::Result<()> { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); chat.config .permissions .approval_policy @@ -8515,6 +8946,7 @@ async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot() #[tokio::test] async fn approval_modal_patch_snapshot() -> anyhow::Result<()> { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); chat.config .permissions .approval_policy @@ -8818,6 +9250,7 @@ async fn status_widget_and_approval_modal_snapshot() { use codex_protocol::protocol::ExecApprovalRequestEvent; let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); // Begin a running task so the status indicator would be active. chat.handle_codex_event(Event { id: "task-1".into(), @@ -8973,6 +9406,7 @@ async fn background_event_updates_status_header() { #[tokio::test] async fn apply_patch_events_emit_history_cells() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); // 1) Approval request -> proposed patch summary cell let mut changes = HashMap::new(); @@ -9072,6 +9506,7 @@ async fn apply_patch_events_emit_history_cells() { #[tokio::test] async fn apply_patch_manual_approval_adjusts_header() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); let mut proposed_changes = HashMap::new(); proposed_changes.insert( @@ -9121,6 +9556,7 @@ async fn apply_patch_manual_approval_adjusts_header() { #[tokio::test] async fn apply_patch_manual_flow_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); let mut proposed_changes = HashMap::new(); proposed_changes.insert( @@ -9174,6 +9610,7 @@ async fn apply_patch_manual_flow_snapshot() { #[tokio::test] async fn apply_patch_approval_sends_op_with_call_id() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); // Simulate receiving an approval request with a distinct event id and call id. let mut changes = HashMap::new(); changes.insert( @@ -9217,6 +9654,7 @@ async fn apply_patch_approval_sends_op_with_call_id() { #[tokio::test] async fn apply_patch_full_flow_integration_like() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); // 1) Backend requests approval let mut changes = HashMap::new(); @@ -9296,6 +9734,7 @@ async fn apply_patch_full_flow_integration_like() { #[tokio::test] async fn apply_patch_untrusted_shows_approval_modal() -> anyhow::Result<()> { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); // Ensure approval policy is untrusted (OnRequest) chat.config .permissions @@ -9346,6 +9785,7 @@ async fn apply_patch_untrusted_shows_approval_modal() -> anyhow::Result<()> { #[tokio::test] async fn apply_patch_request_shows_diff_summary() -> anyhow::Result<()> { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); // Ensure we are in OnRequest so an approval is surfaced chat.config diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 43e0e2afe96..73c2821b7f7 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -521,6 +521,7 @@ pub async fn run_main(mut cli: Cli, arg0_paths: Arg0DispatchPaths) -> std::io::R cli, config, overrides, + arg0_paths, cli_kv_overrides, cloud_requirements, feedback, @@ -533,6 +534,7 @@ async fn run_ratatui_app( cli: Cli, initial_config: Config, overrides: ConfigOverrides, + arg0_paths: Arg0DispatchPaths, cli_kv_overrides: Vec<(String, toml::Value)>, mut cloud_requirements: CloudRequirementsLoader, feedback: codex_feedback::CodexFeedback, @@ -924,9 +926,11 @@ async fn run_ratatui_app( &mut tui, auth_manager, config, + arg0_paths, cli_kv_overrides.clone(), overrides.clone(), active_profile, + cloud_requirements, prompt, images, session_selection, diff --git a/codex-rs/tui/src/session_log.rs b/codex-rs/tui/src/session_log.rs index e512c2f3cd6..1fbf028deb9 100644 --- a/codex-rs/tui/src/session_log.rs +++ b/codex-rs/tui/src/session_log.rs @@ -153,6 +153,16 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) { }); LOGGER.write_json_line(value); } + AppEvent::InsertThreadHistoryCell { thread_id, cell } => { + let value = json!({ + "ts": now_ts(), + "dir": "to_tui", + "kind": "insert_thread_history_cell", + "thread_id": thread_id.to_string(), + "lines": cell.transcript_lines(u16::MAX).len(), + }); + LOGGER.write_json_line(value); + } AppEvent::StartFileSearch(query) => { let value = json!({ "ts": now_ts(),