diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d0c6c390d..9ead5c6e1 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1489,7 +1489,6 @@ dependencies = [ "codex-app-server-protocol", "codex-arg0", "codex-core", - "codex-features", "codex-feedback", "codex-protocol", "futures", @@ -1999,9 +1998,7 @@ dependencies = [ "codex-utils-absolute-path", "codex-utils-cargo-bin", "codex-utils-cli", - "codex-utils-elapsed", "codex-utils-oss", - "codex-utils-sandbox-summary", "core_test_support", "libc", "opentelemetry", @@ -2009,10 +2006,8 @@ dependencies = [ "owo-colors", "predicates", "pretty_assertions", - "rmcp", "serde", "serde_json", - "shlex", "supports-color 3.0.2", "tempfile", "tokio", diff --git a/codex-rs/app-server-client/Cargo.toml b/codex-rs/app-server-client/Cargo.toml index 5a3a1aa73..a0b98c0fe 100644 --- a/codex-rs/app-server-client/Cargo.toml +++ b/codex-rs/app-server-client/Cargo.toml @@ -16,7 +16,6 @@ codex-app-server = { workspace = true } codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } codex-core = { workspace = true } -codex-features = { workspace = true } codex-feedback = { workspace = true } codex-protocol = { workspace = true } futures = { workspace = true } diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index acf9c77a1..115e9808f 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -35,19 +35,14 @@ use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCErrorError; -use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::Result as JsonRpcResult; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_arg0::Arg0DispatchPaths; -use codex_core::AuthManager; -use codex_core::ThreadManager; use codex_core::config::Config; use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::LoaderOverrides; -use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig; -use codex_features::Feature; use codex_feedback::CodexFeedback; use codex_protocol::protocol::SessionSource; use serde::de::DeserializeOwned; @@ -73,7 +68,6 @@ pub type RequestResult = std::result::Result; pub enum AppServerEvent { Lagged { skipped: usize }, ServerNotification(ServerNotification), - LegacyNotification(JSONRPCNotification), ServerRequest(ServerRequest), Disconnected { message: String }, } @@ -85,9 +79,6 @@ impl From for AppServerEvent { InProcessServerEvent::ServerNotification(notification) => { Self::ServerNotification(notification) } - InProcessServerEvent::LegacyNotification(notification) => { - Self::LegacyNotification(notification) - } InProcessServerEvent::ServerRequest(request) => Self::ServerRequest(request), } } @@ -97,19 +88,12 @@ fn event_requires_delivery(event: &InProcessServerEvent) -> bool { // These terminal events drive surface shutdown/completion state. Dropping // them under backpressure can leave exec/TUI waiting forever even though // the underlying turn has already ended. - match event { + matches!( + event, InProcessServerEvent::ServerNotification( codex_app_server_protocol::ServerNotification::TurnCompleted(_), - ) => true, - InProcessServerEvent::LegacyNotification(notification) => matches!( - notification - .method - .strip_prefix("codex/event/") - .unwrap_or(¬ification.method), - "task_complete" | "turn_aborted" | "shutdown_complete" - ), - _ => false, - } + ) + ) } /// Layered error for [`InProcessAppServerClient::request_typed`]. @@ -159,16 +143,6 @@ impl Error for TypedRequestError { } } -#[derive(Clone)] -struct SharedCoreManagers { - // Temporary bootstrap escape hatch for embedders that still need direct - // core handles during the in-process app-server migration. Once TUI/exec - // stop depending on direct manager access, remove this wrapper and keep - // manager ownership entirely inside the app-server runtime. - auth_manager: Arc, - thread_manager: Arc, -} - #[derive(Clone)] pub struct InProcessClientStartArgs { /// Resolved argv0 dispatch paths used by command execution internals. @@ -202,30 +176,6 @@ pub struct InProcessClientStartArgs { } impl InProcessClientStartArgs { - fn shared_core_managers(&self) -> SharedCoreManagers { - let auth_manager = AuthManager::shared( - self.config.codex_home.clone(), - self.enable_codex_api_key_env, - self.config.cli_auth_credentials_store_mode, - ); - let thread_manager = Arc::new(ThreadManager::new( - self.config.as_ref(), - auth_manager.clone(), - self.session_source.clone(), - CollaborationModesConfig { - default_mode_request_user_input: self - .config - .features - .enabled(Feature::DefaultModeRequestUserInput), - }, - )); - - SharedCoreManagers { - auth_manager, - thread_manager, - } - } - /// Builds initialize params from caller-provided metadata. pub fn initialize_params(&self) -> InitializeParams { let capabilities = InitializeCapabilities { @@ -247,7 +197,7 @@ impl InProcessClientStartArgs { } } - fn into_runtime_start_args(self, shared_core: &SharedCoreManagers) -> InProcessStartArgs { + fn into_runtime_start_args(self) -> InProcessStartArgs { let initialize = self.initialize_params(); InProcessStartArgs { arg0_paths: self.arg0_paths, @@ -255,8 +205,6 @@ impl InProcessClientStartArgs { cli_overrides: self.cli_overrides, loader_overrides: self.loader_overrides, cloud_requirements: self.cloud_requirements, - auth_manager: Some(shared_core.auth_manager.clone()), - thread_manager: Some(shared_core.thread_manager.clone()), feedback: self.feedback, config_warnings: self.config_warnings, session_source: self.session_source, @@ -310,8 +258,6 @@ pub struct InProcessAppServerClient { command_tx: mpsc::Sender, event_rx: mpsc::Receiver, worker_handle: tokio::task::JoinHandle<()>, - auth_manager: Arc, - thread_manager: Arc, } #[derive(Clone)] @@ -338,9 +284,8 @@ impl InProcessAppServerClient { /// with overload error instead of being silently dropped. pub async fn start(args: InProcessClientStartArgs) -> IoResult { let channel_capacity = args.channel_capacity.max(1); - let shared_core = args.shared_core_managers(); let mut handle = - codex_app_server::in_process::start(args.into_runtime_start_args(&shared_core)).await?; + codex_app_server::in_process::start(args.into_runtime_start_args()).await?; let request_sender = handle.sender(); let (command_tx, mut command_rx) = mpsc::channel::(channel_capacity); let (event_tx, event_rx) = mpsc::channel::(channel_capacity); @@ -401,6 +346,25 @@ impl InProcessAppServerClient { let Some(event) = event else { break; }; + if let InProcessServerEvent::ServerRequest( + ServerRequest::ChatgptAuthTokensRefresh { request_id, .. } + ) = &event + { + let send_result = request_sender.fail_server_request( + request_id.clone(), + JSONRPCErrorError { + code: -32000, + message: "chatgpt auth token refresh is not supported for in-process app-server clients".to_string(), + data: None, + }, + ); + if let Err(err) = send_result { + warn!( + "failed to reject unsupported chatgpt auth token refresh request: {err}" + ); + } + continue; + } if skipped_events > 0 { if event_requires_delivery(&event) { @@ -491,21 +455,9 @@ impl InProcessAppServerClient { command_tx, event_rx, worker_handle, - auth_manager: shared_core.auth_manager, - thread_manager: shared_core.thread_manager, }) } - /// Temporary bootstrap escape hatch for embedders migrating toward RPC-only usage. - pub fn auth_manager(&self) -> Arc { - self.auth_manager.clone() - } - - /// Temporary bootstrap escape hatch for embedders migrating toward RPC-only usage. - pub fn thread_manager(&self) -> Arc { - self.thread_manager.clone() - } - pub fn request_handle(&self) -> InProcessAppServerRequestHandle { InProcessAppServerRequestHandle { command_tx: self.command_tx.clone(), @@ -664,8 +616,6 @@ impl InProcessAppServerClient { command_tx, event_rx, worker_handle, - auth_manager: _, - thread_manager: _, } = self; let mut worker_handle = worker_handle; // Drop the caller-facing receiver before asking the worker to shut @@ -857,8 +807,6 @@ mod tests { use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ToolRequestUserInputParams; use codex_app_server_protocol::ToolRequestUserInputQuestion; - use codex_core::AuthManager; - use codex_core::ThreadManager; use codex_core::config::ConfigBuilder; use futures::SinkExt; use futures::StreamExt; @@ -1052,7 +1000,7 @@ mod tests { } #[tokio::test] - async fn shared_thread_manager_tracks_threads_started_via_app_server() { + async fn threads_started_via_app_server_are_visible_through_typed_requests() { let client = start_test_client(SessionSource::Cli).await; let response: ThreadStartResponse = client @@ -1065,17 +1013,19 @@ mod tests { }) .await .expect("thread/start should succeed"); - let created_thread_id = codex_protocol::ThreadId::from_string(&response.thread.id) - .expect("thread id should parse"); - timeout( - Duration::from_secs(2), - client.thread_manager().get_thread(created_thread_id), - ) - .await - .expect("timed out waiting for retained thread manager to observe started thread") - .expect("started thread should be visible through the shared thread manager"); - let thread_ids = client.thread_manager().list_thread_ids().await; - assert!(thread_ids.contains(&created_thread_id)); + let read = client + .request_typed::( + ClientRequest::ThreadRead { + request_id: RequestId::Integer(4), + params: codex_app_server_protocol::ThreadReadParams { + thread_id: response.thread.id.clone(), + include_turns: false, + }, + }, + ) + .await + .expect("thread/read should return the newly started thread"); + assert_eq!(read.thread.id, response.thread.id); client.shutdown().await.expect("shutdown should complete"); } @@ -1472,22 +1422,6 @@ mod tests { let (command_tx, _command_rx) = mpsc::channel(1); let (event_tx, event_rx) = mpsc::channel(1); let worker_handle = tokio::spawn(async {}); - 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.clone(), - SessionSource::Exec, - CollaborationModesConfig { - default_mode_request_user_input: config - .features - .enabled(Feature::DefaultModeRequestUserInput), - }, - )); event_tx .send(InProcessServerEvent::Lagged { skipped: 3 }) .await @@ -1498,8 +1432,6 @@ mod tests { command_tx, event_rx, worker_handle, - auth_manager, - thread_manager, }; let event = timeout(Duration::from_secs(2), client.next_event()) @@ -1530,37 +1462,38 @@ mod tests { ) ) )); - assert!(event_requires_delivery( - &InProcessServerEvent::LegacyNotification( - codex_app_server_protocol::JSONRPCNotification { - method: "codex/event/turn_aborted".to_string(), - params: None, - } - ) - )); assert!(!event_requires_delivery(&InProcessServerEvent::Lagged { skipped: 1 })); } #[tokio::test] - async fn accessors_expose_retained_shared_managers() { - let client = start_test_client(SessionSource::Cli).await; + async fn runtime_start_args_leave_manager_bootstrap_to_app_server() { + let config = Arc::new(build_test_config().await); - assert!( - Arc::ptr_eq(&client.auth_manager(), &client.auth_manager()), - "auth_manager accessor should clone the retained shared manager" - ); - assert!( - Arc::ptr_eq(&client.thread_manager(), &client.thread_manager()), - "thread_manager accessor should clone the retained shared manager" - ); + let runtime_args = InProcessClientStartArgs { + arg0_paths: Arg0DispatchPaths::default(), + config: config.clone(), + cli_overrides: Vec::new(), + loader_overrides: LoaderOverrides::default(), + cloud_requirements: CloudRequirementsLoader::default(), + feedback: CodexFeedback::new(), + config_warnings: Vec::new(), + session_source: SessionSource::Exec, + 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, + } + .into_runtime_start_args(); - client.shutdown().await.expect("shutdown should complete"); + assert_eq!(runtime_args.config, config); } #[tokio::test] - async fn shutdown_completes_promptly_with_retained_shared_managers() { + async fn shutdown_completes_promptly_without_retained_managers() { let client = start_test_client(SessionSource::Cli).await; timeout(Duration::from_secs(1), client.shutdown()) diff --git a/codex-rs/app-server-client/src/remote.rs b/codex-rs/app-server-client/src/remote.rs index b74759520..b9179716f 100644 --- a/codex-rs/app-server-client/src/remote.rs +++ b/codex-rs/app-server-client/src/remote.rs @@ -272,18 +272,19 @@ impl RemoteAppServerClient { } } Ok(JSONRPCMessage::Notification(notification)) => { - let event = app_server_event_from_notification(notification); - if let Err(err) = deliver_event( - &event_tx, - &mut skipped_events, - event, - &mut stream, - ) - .await - { - warn!(%err, "failed to deliver remote app-server event"); - break; - } + if let Some(event) = + app_server_event_from_notification(notification) + && let Err(err) = deliver_event( + &event_tx, + &mut skipped_events, + event, + &mut stream, + ) + .await + { + warn!(%err, "failed to deliver remote app-server event"); + break; + } } Ok(JSONRPCMessage::Request(request)) => { let request_id = request.id.clone(); @@ -673,7 +674,9 @@ async fn initialize_remote_connection( ))); } JSONRPCMessage::Notification(notification) => { - pending_events.push(app_server_event_from_notification(notification)); + if let Some(event) = app_server_event_from_notification(notification) { + pending_events.push(event); + } } JSONRPCMessage::Request(request) => { let request_id = request.id.clone(); @@ -756,10 +759,10 @@ async fn initialize_remote_connection( Ok(pending_events) } -fn app_server_event_from_notification(notification: JSONRPCNotification) -> AppServerEvent { - match ServerNotification::try_from(notification.clone()) { - Ok(notification) => AppServerEvent::ServerNotification(notification), - Err(_) => AppServerEvent::LegacyNotification(notification), +fn app_server_event_from_notification(notification: JSONRPCNotification) -> Option { + match ServerNotification::try_from(notification) { + Ok(notification) => Some(AppServerEvent::ServerNotification(notification)), + Err(_) => None, } } @@ -852,13 +855,6 @@ async fn reject_if_server_request_dropped( fn event_requires_delivery(event: &AppServerEvent) -> bool { match event { AppServerEvent::ServerNotification(ServerNotification::TurnCompleted(_)) => true, - AppServerEvent::LegacyNotification(notification) => matches!( - notification - .method - .strip_prefix("codex/event/") - .unwrap_or(¬ification.method), - "task_complete" | "turn_aborted" | "shutdown_complete" - ), AppServerEvent::Disconnected { .. } => true, AppServerEvent::Lagged { .. } | AppServerEvent::ServerNotification(_) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 37ff0ca4f..c4c2799ca 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -12,7 +12,6 @@ use crate::models::supported_models; use crate::outgoing_message::ConnectionId; use crate::outgoing_message::ConnectionRequestId; use crate::outgoing_message::OutgoingMessageSender; -use crate::outgoing_message::OutgoingNotification; use crate::outgoing_message::RequestContext; use crate::outgoing_message::ThreadScopedOutgoingMessageSender; use crate::thread_status::ThreadWatchManager; @@ -6831,43 +6830,9 @@ impl CodexMessageProcessor { } }; - // For now, we send a notification for every event, - // Legacy `codex/event/*` notifications are still - // produced here because the in-process app-server lane - // (`codex exec` and other in-process consumers) still - // depends on them. External transports now drop - // `OutgoingMessage::Notification` in `transport.rs`, - // so stdio/websocket clients only observe the typed - // `ServerNotification` translations emitted below. - // - // TODO: remove this raw legacy-notification emission - // entirely once the remaining in-process consumers are - // migrated off `codex/event/*`. - let event_formatted = match &event.msg { - EventMsg::TurnStarted(_) => "task_started", - EventMsg::TurnComplete(_) => "task_complete", - _ => &event.msg.to_string(), - }; - let request_event_name = format!("codex/event/{event_formatted}"); - tracing::trace!( - conversation_id = %conversation_id, - "app-server event: {request_event_name}" - ); - let mut params = match serde_json::to_value(event.clone()) { - Ok(serde_json::Value::Object(map)) => map, - Ok(_) => { - error!("event did not serialize to an object"); - continue; - } - Err(err) => { - error!("failed to serialize event: {err}"); - continue; - } - }; - params.insert( - "conversationId".to_string(), - conversation_id.to_string().into(), - ); + // Track the event before emitting any typed + // translations so thread-local state such as raw event + // opt-in stays synchronized with the conversation. let raw_events_enabled = { let mut thread_state = thread_state.lock().await; thread_state.track_current_turn_event(&event.msg); @@ -6880,18 +6845,6 @@ impl CodexMessageProcessor { continue; } - if !subscribed_connection_ids.is_empty() { - outgoing_for_task - .send_notification_to_connections( - &subscribed_connection_ids, - OutgoingNotification { - method: request_event_name, - params: Some(params.into()), - }, - ) - .await; - } - let thread_outgoing = ThreadScopedOutgoingMessageSender::new( outgoing_for_task.clone(), subscribed_connection_ids, diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 4288d1539..0826f33c6 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -68,14 +68,11 @@ use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCErrorError; -use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::RequestId; 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::AuthManager; -use codex_core::ThreadManager; use codex_core::config::Config; use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::LoaderOverrides; @@ -98,16 +95,6 @@ fn server_notification_requires_delivery(notification: &ServerNotification) -> b matches!(notification, ServerNotification::TurnCompleted(_)) } -fn legacy_notification_requires_delivery(notification: &JSONRPCNotification) -> bool { - matches!( - notification - .method - .strip_prefix("codex/event/") - .unwrap_or(¬ification.method), - "task_complete" | "turn_aborted" | "shutdown_complete" - ) -} - /// Input needed to start an in-process app-server runtime. /// /// These fields mirror the pieces of ambient process state that stdio and @@ -124,10 +111,6 @@ pub struct InProcessStartArgs { pub loader_overrides: LoaderOverrides, /// Preloaded cloud requirements provider. pub cloud_requirements: CloudRequirementsLoader, - /// Optional prebuilt auth manager reused by an embedding caller. - pub auth_manager: Option>, - /// Optional prebuilt thread manager reused by an embedding caller. - pub thread_manager: Option>, /// Feedback sink used by app-server/core telemetry and logs. pub feedback: CodexFeedback, /// Startup warnings emitted after initialize succeeds. @@ -144,11 +127,6 @@ pub struct InProcessStartArgs { /// Event emitted from the app-server to the in-process client. /// -/// The stream carries three event families because CLI surfaces are mid-migration -/// from the legacy `codex_protocol::Event` model to the typed app-server -/// notification model. Once all surfaces consume only [`ServerNotification`], -/// [`LegacyNotification`](Self::LegacyNotification) can be removed. -/// /// [`Lagged`](Self::Lagged) is a transport health marker, not an application /// event — it signals that the consumer fell behind and some events were dropped. #[derive(Debug, Clone)] @@ -157,8 +135,6 @@ pub enum InProcessServerEvent { ServerRequest(ServerRequest), /// App-server notification directed to the embedded client. ServerNotification(ServerNotification), - /// Legacy JSON-RPC notification from core event bridge. - LegacyNotification(JSONRPCNotification), /// Indicates one or more events were dropped due to backpressure. Lagged { skipped: usize }, } @@ -390,7 +366,6 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { Arc::clone(&outbound_initialized), Arc::clone(&outbound_experimental_api_enabled), Arc::clone(&outbound_opted_out_notification_methods), - /*allow_legacy_notifications*/ true, /*disconnect_sender*/ None, ), ); @@ -410,8 +385,6 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { cli_overrides: args.cli_overrides, loader_overrides: args.loader_overrides, cloud_requirements: args.cloud_requirements, - auth_manager: args.auth_manager, - thread_manager: args.thread_manager, feedback: args.feedback, log_db: None, config_warnings: args.config_warnings, @@ -655,32 +628,6 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { } } } - OutgoingMessage::Notification(notification) => { - let notification = JSONRPCNotification { - method: notification.method, - params: notification.params, - }; - if legacy_notification_requires_delivery(¬ification) { - if event_tx - .send(InProcessServerEvent::LegacyNotification(notification)) - .await - .is_err() - { - break; - } - } else if let Err(send_error) = - event_tx.try_send(InProcessServerEvent::LegacyNotification(notification)) - { - match send_error { - mpsc::error::TrySendError::Full(_) => { - warn!("dropping in-process legacy notification (queue full)"); - } - mpsc::error::TrySendError::Closed(_) => { - break; - } - } - } - } } } } @@ -759,8 +706,6 @@ mod tests { cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), cloud_requirements: CloudRequirementsLoader::default(), - auth_manager: None, - thread_manager: None, feedback: CodexFeedback::new(), config_warnings: Vec::new(), session_source, @@ -858,7 +803,7 @@ mod tests { } #[test] - fn guaranteed_delivery_helpers_cover_terminal_notifications() { + fn guaranteed_delivery_helpers_cover_terminal_server_notifications() { assert!(server_notification_requires_delivery( &ServerNotification::TurnCompleted(TurnCompletedNotification { thread_id: "thread-1".to_string(), @@ -870,30 +815,5 @@ mod tests { }, }) )); - - assert!(legacy_notification_requires_delivery( - &JSONRPCNotification { - method: "codex/event/task_complete".to_string(), - params: None, - } - )); - assert!(legacy_notification_requires_delivery( - &JSONRPCNotification { - method: "codex/event/turn_aborted".to_string(), - params: None, - } - )); - assert!(legacy_notification_requires_delivery( - &JSONRPCNotification { - method: "codex/event/shutdown_complete".to_string(), - params: None, - } - )); - assert!(!legacy_notification_requires_delivery( - &JSONRPCNotification { - method: "codex/event/item_started".to_string(), - params: None, - } - )); } } diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index a4994d34b..f044ddd2e 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -104,8 +104,6 @@ enum OutboundControlEvent { Opened { connection_id: ConnectionId, writer: mpsc::Sender, - // Allow codex/event/* notifications to be emitted. - allow_legacy_notifications: bool, disconnect_sender: Option, initialized: Arc, experimental_api_enabled: Arc, @@ -562,7 +560,6 @@ pub async fn run_main_with_transport( OutboundControlEvent::Opened { connection_id, writer, - allow_legacy_notifications, disconnect_sender, initialized, experimental_api_enabled, @@ -575,7 +572,6 @@ pub async fn run_main_with_transport( initialized, experimental_api_enabled, opted_out_notification_methods, - allow_legacy_notifications, disconnect_sender, ), ); @@ -618,8 +614,6 @@ pub async fn run_main_with_transport( cli_overrides, loader_overrides, cloud_requirements: cloud_requirements.clone(), - auth_manager: None, - thread_manager: None, feedback: feedback.clone(), log_db, config_warnings, @@ -675,7 +669,6 @@ pub async fn run_main_with_transport( TransportEvent::ConnectionOpened { connection_id, writer, - allow_legacy_notifications, disconnect_sender, } => { let outbound_initialized = Arc::new(AtomicBool::new(false)); @@ -687,7 +680,6 @@ pub async fn run_main_with_transport( .send(OutboundControlEvent::Opened { connection_id, writer, - allow_legacy_notifications, disconnect_sender, initialized: Arc::clone(&outbound_initialized), experimental_api_enabled: Arc::clone( diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 287fe7975..4f53bfc1a 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -172,8 +172,6 @@ pub(crate) struct MessageProcessorArgs { pub(crate) cli_overrides: Vec<(String, TomlValue)>, pub(crate) loader_overrides: LoaderOverrides, pub(crate) cloud_requirements: CloudRequirementsLoader, - pub(crate) auth_manager: Option>, - pub(crate) thread_manager: Option>, pub(crate) feedback: CodexFeedback, pub(crate) log_db: Option, pub(crate) config_warnings: Vec, @@ -192,36 +190,27 @@ impl MessageProcessor { cli_overrides, loader_overrides, cloud_requirements, - auth_manager, - thread_manager, feedback, log_db, config_warnings, session_source, enable_codex_api_key_env, } = args; - let (auth_manager, thread_manager) = match (auth_manager, thread_manager) { - (Some(auth_manager), Some(thread_manager)) => (auth_manager, thread_manager), - (None, None) => { - let auth_manager = AuthManager::shared( - config.codex_home.clone(), - enable_codex_api_key_env, - config.cli_auth_credentials_store_mode, - ); - let thread_manager = Arc::new(ThreadManager::new( - config.as_ref(), - auth_manager.clone(), - session_source, - CollaborationModesConfig { - default_mode_request_user_input: config - .features - .enabled(Feature::DefaultModeRequestUserInput), - }, - )); - (auth_manager, thread_manager) - } - _ => panic!("MessageProcessorArgs must provide both auth_manager and thread_manager"), - }; + let auth_manager = AuthManager::shared( + config.codex_home.clone(), + enable_codex_api_key_env, + config.cli_auth_credentials_store_mode, + ); + let thread_manager = Arc::new(ThreadManager::new( + config.as_ref(), + auth_manager.clone(), + session_source, + CollaborationModesConfig { + default_mode_request_user_input: config + .features + .enabled(Feature::DefaultModeRequestUserInput), + }, + )); 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(), 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 58499745b..51a89280a 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -239,8 +239,6 @@ fn build_test_processor( cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), cloud_requirements: CloudRequirementsLoader::default(), - auth_manager: None, - thread_manager: None, feedback: CodexFeedback::new(), log_db: None, config_warnings: Vec::new(), diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 67761525b..5802eb44c 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -527,38 +527,6 @@ impl OutgoingMessageSender { } } - pub(crate) async fn send_notification_to_connections( - &self, - connection_ids: &[ConnectionId], - notification: OutgoingNotification, - ) { - let outgoing_message = OutgoingMessage::Notification(notification); - if connection_ids.is_empty() { - if let Err(err) = self - .sender - .send(OutgoingEnvelope::Broadcast { - message: outgoing_message, - }) - .await - { - warn!("failed to send notification to client: {err:?}"); - } - return; - } - for connection_id in connection_ids { - if let Err(err) = self - .sender - .send(OutgoingEnvelope::ToConnection { - connection_id: *connection_id, - message: outgoing_message.clone(), - }) - .await - { - warn!("failed to send notification to client: {err:?}"); - } - } - } - pub(crate) async fn send_error( &self, request_id: ConnectionRequestId, @@ -616,7 +584,6 @@ impl OutgoingMessageSender { #[serde(untagged)] pub(crate) enum OutgoingMessage { Request(ServerRequest), - Notification(OutgoingNotification), /// AppServerNotification is specific to the case where this is run as an /// "app server" as opposed to an MCP server. AppServerNotification(ServerNotification), @@ -624,13 +591,6 @@ pub(crate) enum OutgoingMessage { Error(OutgoingError), } -#[derive(Debug, Clone, PartialEq, Serialize)] -pub(crate) struct OutgoingNotification { - pub method: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub params: Option, -} - #[derive(Debug, Clone, PartialEq, Serialize)] pub(crate) struct OutgoingResponse { pub id: RequestId, diff --git a/codex-rs/app-server/src/transport.rs b/codex-rs/app-server/src/transport.rs index 3e24d831a..5fd9fa42c 100644 --- a/codex-rs/app-server/src/transport.rs +++ b/codex-rs/app-server/src/transport.rs @@ -188,7 +188,6 @@ pub(crate) enum TransportEvent { ConnectionOpened { connection_id: ConnectionId, writer: mpsc::Sender, - allow_legacy_notifications: bool, disconnect_sender: Option, }, ConnectionClosed { @@ -226,7 +225,6 @@ pub(crate) struct OutboundConnectionState { pub(crate) initialized: Arc, pub(crate) experimental_api_enabled: Arc, pub(crate) opted_out_notification_methods: Arc>>, - pub(crate) allow_legacy_notifications: bool, pub(crate) writer: mpsc::Sender, disconnect_sender: Option, } @@ -237,14 +235,12 @@ impl OutboundConnectionState { initialized: Arc, experimental_api_enabled: Arc, opted_out_notification_methods: Arc>>, - allow_legacy_notifications: bool, disconnect_sender: Option, ) -> Self { Self { initialized, experimental_api_enabled, opted_out_notification_methods, - allow_legacy_notifications, writer, disconnect_sender, } @@ -272,7 +268,6 @@ pub(crate) async fn start_stdio_connection( .send(TransportEvent::ConnectionOpened { connection_id, writer: writer_tx, - allow_legacy_notifications: false, disconnect_sender: None, }) .await @@ -376,7 +371,6 @@ async fn run_websocket_connection( .send(TransportEvent::ConnectionOpened { connection_id, writer: writer_tx, - allow_legacy_notifications: false, disconnect_sender: Some(disconnect_token.clone()), }) .await @@ -584,16 +578,6 @@ fn should_skip_notification_for_connection( connection_state: &OutboundConnectionState, message: &OutgoingMessage, ) -> bool { - if !connection_state.allow_legacy_notifications - && matches!(message, OutgoingMessage::Notification(_)) - { - // Raw legacy `codex/event/*` notifications are still emitted upstream - // for in-process compatibility, but they are no longer part of the - // external app-server contract. Keep dropping them here until the - // producer path can be deleted entirely. - return true; - } - let Ok(opted_out_notification_methods) = connection_state.opted_out_notification_methods.read() else { warn!("failed to read outbound opted-out notifications"); @@ -604,9 +588,6 @@ fn should_skip_notification_for_connection( let method = notification.to_string(); opted_out_notification_methods.contains(method.as_str()) } - OutgoingMessage::Notification(notification) => { - opted_out_notification_methods.contains(notification.method.as_str()) - } _ => false, } } @@ -719,6 +700,8 @@ mod tests { use super::*; use crate::error_code::OVERLOADED_ERROR_CODE; use codex_app_server_protocol::CommandExecutionRequestApprovalSkillMetadata; + use codex_app_server_protocol::ConfigWarningNotification; + use codex_app_server_protocol::ServerNotification; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use serde_json::json; @@ -921,11 +904,13 @@ mod tests { .expect("transport queue should accept first message"); writer_tx - .send(OutgoingMessage::Notification( - crate::outgoing_message::OutgoingNotification { - method: "queued".to_string(), - params: None, - }, + .send(OutgoingMessage::AppServerNotification( + ServerNotification::ConfigWarning(ConfigWarningNotification { + summary: "queued".to_string(), + details: None, + path: None, + range: None, + }), )) .await .expect("writer queue should accept first message"); @@ -950,7 +935,16 @@ mod tests { .await .expect("writer queue should still contain original message"); let queued_json = serde_json::to_value(queued_outgoing).expect("serialize queued message"); - assert_eq!(queued_json, json!({ "method": "queued" })); + assert_eq!( + queued_json, + json!({ + "method": "configWarning", + "params": { + "summary": "queued", + "details": null, + }, + }) + ); } #[tokio::test] @@ -958,9 +952,8 @@ mod tests { let connection_id = ConnectionId(7); let (writer_tx, mut writer_rx) = mpsc::channel(1); let initialized = Arc::new(AtomicBool::new(true)); - let opted_out_notification_methods = Arc::new(RwLock::new(HashSet::from([ - "codex/event/task_started".to_string(), - ]))); + let opted_out_notification_methods = + Arc::new(RwLock::new(HashSet::from(["configWarning".to_string()]))); let mut connections = HashMap::new(); connections.insert( @@ -970,7 +963,6 @@ mod tests { initialized, Arc::new(AtomicBool::new(true)), opted_out_notification_methods, - false, None, ), ); @@ -979,12 +971,14 @@ mod tests { &mut connections, OutgoingEnvelope::ToConnection { connection_id, - message: OutgoingMessage::Notification( - crate::outgoing_message::OutgoingNotification { - method: "codex/event/task_started".to_string(), - params: None, + message: OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { + summary: "task_started".to_string(), + details: None, + path: None, + range: None, }, - ), + )), }, ) .await; @@ -995,89 +989,6 @@ mod tests { ); } - #[tokio::test] - async fn to_connection_legacy_notifications_are_dropped_for_external_clients() { - let connection_id = ConnectionId(10); - let (writer_tx, mut writer_rx) = mpsc::channel(1); - - let mut connections = HashMap::new(); - connections.insert( - connection_id, - OutboundConnectionState::new( - writer_tx, - Arc::new(AtomicBool::new(true)), - Arc::new(AtomicBool::new(true)), - Arc::new(RwLock::new(HashSet::new())), - false, - None, - ), - ); - - route_outgoing_envelope( - &mut connections, - OutgoingEnvelope::ToConnection { - connection_id, - message: OutgoingMessage::Notification( - crate::outgoing_message::OutgoingNotification { - method: "codex/event/task_started".to_string(), - params: None, - }, - ), - }, - ) - .await; - - assert!( - writer_rx.try_recv().is_err(), - "legacy notifications should not reach external clients" - ); - } - - #[tokio::test] - async fn to_connection_legacy_notifications_are_preserved_for_in_process_clients() { - let connection_id = ConnectionId(11); - let (writer_tx, mut writer_rx) = mpsc::channel(1); - - let mut connections = HashMap::new(); - connections.insert( - connection_id, - OutboundConnectionState::new( - writer_tx, - Arc::new(AtomicBool::new(true)), - Arc::new(AtomicBool::new(true)), - Arc::new(RwLock::new(HashSet::new())), - true, - None, - ), - ); - - route_outgoing_envelope( - &mut connections, - OutgoingEnvelope::ToConnection { - connection_id, - message: OutgoingMessage::Notification( - crate::outgoing_message::OutgoingNotification { - method: "codex/event/task_started".to_string(), - params: None, - }, - ), - }, - ) - .await; - - let message = writer_rx - .recv() - .await - .expect("legacy notification should reach in-process clients"); - assert!(matches!( - message, - OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification { - method, - params: None, - }) if method == "codex/event/task_started" - )); - } - #[tokio::test] async fn command_execution_request_approval_strips_experimental_fields_without_capability() { let connection_id = ConnectionId(8); @@ -1091,7 +1002,6 @@ mod tests { Arc::new(AtomicBool::new(true)), Arc::new(AtomicBool::new(false)), Arc::new(RwLock::new(HashSet::new())), - false, None, ), ); @@ -1158,7 +1068,6 @@ mod tests { Arc::new(AtomicBool::new(true)), Arc::new(AtomicBool::new(true)), Arc::new(RwLock::new(HashSet::new())), - false, None, ), ); @@ -1246,7 +1155,6 @@ mod tests { Arc::new(AtomicBool::new(true)), Arc::new(AtomicBool::new(true)), Arc::new(RwLock::new(HashSet::new())), - false, Some(fast_disconnect_token.clone()), ), ); @@ -1257,25 +1165,30 @@ mod tests { Arc::new(AtomicBool::new(true)), Arc::new(AtomicBool::new(true)), Arc::new(RwLock::new(HashSet::new())), - false, Some(slow_disconnect_token.clone()), ), ); - let queued_message = - OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification { - method: "codex/event/already-buffered".to_string(), - params: None, - }); + let queued_message = OutgoingMessage::AppServerNotification( + ServerNotification::ConfigWarning(ConfigWarningNotification { + summary: "already-buffered".to_string(), + details: None, + path: None, + range: None, + }), + ); slow_writer_tx .try_send(queued_message) .expect("channel should have room"); - let broadcast_message = - OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification { - method: "codex/event/test".to_string(), - params: None, - }); + let broadcast_message = OutgoingMessage::AppServerNotification( + ServerNotification::ConfigWarning(ConfigWarningNotification { + summary: "test".to_string(), + details: None, + path: None, + range: None, + }), + ); timeout( Duration::from_millis(100), route_outgoing_envelope( @@ -1286,24 +1199,28 @@ mod tests { ), ) .await - .expect("broadcast should return even when legacy notifications are dropped"); - assert!(connections.contains_key(&slow_connection_id)); - assert!(!slow_disconnect_token.is_cancelled()); + .expect("broadcast should return even when one connection is slow"); + assert!(!connections.contains_key(&slow_connection_id)); + assert!(slow_disconnect_token.is_cancelled()); assert!(!fast_disconnect_token.is_cancelled()); - assert!( - fast_writer_rx.try_recv().is_err(), - "broadcast legacy notification should be dropped for fast connections" - ); + let fast_message = fast_writer_rx + .try_recv() + .expect("fast connection should receive the broadcast notification"); + assert!(matches!( + fast_message, + OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { summary, .. } + )) if summary == "test" + )); let slow_message = slow_writer_rx .try_recv() .expect("slow connection should retain its original buffered message"); assert!(matches!( slow_message, - OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification { - method, - params: None, - }) if method == "codex/event/already-buffered" + OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { summary, .. } + )) if summary == "already-buffered" )); } @@ -1312,11 +1229,13 @@ mod tests { let connection_id = ConnectionId(3); let (writer_tx, mut writer_rx) = mpsc::channel(1); writer_tx - .send(OutgoingMessage::Notification( - crate::outgoing_message::OutgoingNotification { - method: "queued".to_string(), - params: None, - }, + .send(OutgoingMessage::AppServerNotification( + ServerNotification::ConfigWarning(ConfigWarningNotification { + summary: "queued".to_string(), + details: None, + path: None, + range: None, + }), )) .await .expect("channel should accept the first queued message"); @@ -1329,7 +1248,6 @@ mod tests { Arc::new(AtomicBool::new(true)), Arc::new(AtomicBool::new(true)), Arc::new(RwLock::new(HashSet::new())), - false, None, ), ); @@ -1339,11 +1257,13 @@ mod tests { &mut connections, OutgoingEnvelope::ToConnection { connection_id, - message: OutgoingMessage::Notification( - crate::outgoing_message::OutgoingNotification { - method: "second".to_string(), - params: None, - }, + message: OutgoingMessage::AppServerNotification( + ServerNotification::ConfigWarning(ConfigWarningNotification { + summary: "second".to_string(), + details: None, + path: None, + range: None, + }), ), }, ) @@ -1356,20 +1276,23 @@ mod tests { .expect("first queued message should exist"); timeout(Duration::from_millis(100), route_task) .await - .expect("routing should finish immediately when legacy notifications are dropped") + .expect("routing should finish after the first queued message is drained") .expect("routing task should succeed"); assert!(matches!( first, - OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification { - method, - params: None, - }) if method == "queued" + OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { summary, .. } + )) if summary == "queued" )); + let second = writer_rx + .try_recv() + .expect("second notification should be delivered once the queue has room"); assert!(matches!( - writer_rx.try_recv(), - Err(tokio::sync::mpsc::error::TryRecvError::Empty) - | Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) + second, + OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { summary, .. } + )) if summary == "second" )); } } diff --git a/codex-rs/exec/Cargo.toml b/codex-rs/exec/Cargo.toml index 378523560..73fc60c39 100644 --- a/codex-rs/exec/Cargo.toml +++ b/codex-rs/exec/Cargo.toml @@ -3,6 +3,7 @@ name = "codex-exec" version.workspace = true edition.workspace = true license.workspace = true +autotests = false [[bin]] name = "codex-exec" @@ -12,6 +13,10 @@ path = "src/main.rs" name = "codex_exec" path = "src/lib.rs" +[[test]] +name = "all" +path = "tests/all.rs" + [lints] workspace = true @@ -28,13 +33,10 @@ codex-otel = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-cli = { workspace = true } -codex-utils-elapsed = { workspace = true } codex-utils-oss = { workspace = true } -codex-utils-sandbox-summary = { workspace = true } owo-colors = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } -shlex = { workspace = true } supports-color = { workspace = true } tokio = { workspace = true, features = [ "io-std", @@ -63,7 +65,6 @@ opentelemetry = { workspace = true } opentelemetry_sdk = { workspace = true } predicates = { workspace = true } pretty_assertions = { workspace = true } -rmcp = { workspace = true } tempfile = { workspace = true } tracing-opentelemetry = { workspace = true } uuid = { workspace = true } diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 6cda7e408..c40237160 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -86,10 +86,6 @@ pub struct Cli { #[arg(long = "color", value_enum, default_value_t = Color::Auto)] pub color: Color, - /// Force cursor-based progress updates in exec mode. - #[arg(long = "progress-cursor", default_value_t = false)] - pub progress_cursor: bool, - /// Print events to stdout as JSONL. #[arg( long = "json", diff --git a/codex-rs/exec/src/event_processor.rs b/codex-rs/exec/src/event_processor.rs index fc9d123e0..4d1da0900 100644 --- a/codex-rs/exec/src/event_processor.rs +++ b/codex-rs/exec/src/event_processor.rs @@ -1,13 +1,13 @@ use std::path::Path; +use codex_app_server_protocol::ServerNotification; use codex_core::config::Config; -use codex_protocol::protocol::Event; use codex_protocol::protocol::SessionConfiguredEvent; -pub(crate) enum CodexStatus { +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CodexStatus { Running, InitiateShutdown, - Shutdown, } pub(crate) trait EventProcessor { @@ -19,8 +19,11 @@ pub(crate) trait EventProcessor { session_configured: &SessionConfiguredEvent, ); - /// Handle a single event emitted by the agent. - fn process_event(&mut self, event: Event) -> CodexStatus; + /// Handle a single typed app-server notification emitted by the agent. + fn process_server_notification(&mut self, notification: ServerNotification) -> CodexStatus; + + /// Handle a local exec warning that is not represented as an app-server notification. + fn process_warning(&mut self, message: String) -> CodexStatus; fn print_final_output(&mut self) {} } diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 2b935fce9..8851233e5 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -1,183 +1,214 @@ +use std::io::IsTerminal; +use std::path::PathBuf; + +use codex_app_server_protocol::CommandExecutionStatus; +use codex_app_server_protocol::McpToolCallStatus; +use codex_app_server_protocol::PatchApplyStatus; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::TurnStatus; +use codex_core::WireApi; use codex_core::config::Config; -use codex_core::web_search::web_search_detail; -use codex_protocol::items::TurnItem; use codex_protocol::num_format::format_with_separators; -use codex_protocol::protocol::AgentMessageEvent; -use codex_protocol::protocol::AgentReasoningRawContentEvent; -use codex_protocol::protocol::AgentStatus; -use codex_protocol::protocol::BackgroundEventEvent; -use codex_protocol::protocol::CollabAgentInteractionBeginEvent; -use codex_protocol::protocol::CollabAgentInteractionEndEvent; -use codex_protocol::protocol::CollabAgentSpawnBeginEvent; -use codex_protocol::protocol::CollabAgentSpawnEndEvent; -use codex_protocol::protocol::CollabCloseBeginEvent; -use codex_protocol::protocol::CollabCloseEndEvent; -use codex_protocol::protocol::CollabWaitingBeginEvent; -use codex_protocol::protocol::CollabWaitingEndEvent; -use codex_protocol::protocol::DeprecationNoticeEvent; -use codex_protocol::protocol::ErrorEvent; -use codex_protocol::protocol::Event; -use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::ExecCommandBeginEvent; -use codex_protocol::protocol::ExecCommandEndEvent; -use codex_protocol::protocol::FileChange; -use codex_protocol::protocol::HookCompletedEvent; -use codex_protocol::protocol::HookEventName; -use codex_protocol::protocol::HookOutputEntryKind; -use codex_protocol::protocol::HookRunStatus; -use codex_protocol::protocol::HookStartedEvent; -use codex_protocol::protocol::ItemCompletedEvent; -use codex_protocol::protocol::McpInvocation; -use codex_protocol::protocol::McpToolCallBeginEvent; -use codex_protocol::protocol::McpToolCallEndEvent; -use codex_protocol::protocol::PatchApplyBeginEvent; -use codex_protocol::protocol::PatchApplyEndEvent; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; -use codex_protocol::protocol::StreamErrorEvent; -use codex_protocol::protocol::TurnAbortReason; -use codex_protocol::protocol::TurnCompleteEvent; -use codex_protocol::protocol::TurnDiffEvent; -use codex_protocol::protocol::WarningEvent; -use codex_protocol::protocol::WebSearchEndEvent; -use codex_utils_elapsed::format_duration; -use codex_utils_elapsed::format_elapsed; use owo_colors::OwoColorize; use owo_colors::Style; -use serde::Deserialize; -use shlex::try_join; -use std::collections::HashMap; -use std::io::IsTerminal; -use std::io::Write; -use std::path::PathBuf; -use std::time::Duration; -use std::time::Instant; use crate::event_processor::CodexStatus; use crate::event_processor::EventProcessor; use crate::event_processor::handle_last_message; -use codex_protocol::plan_tool::StepStatus; -use codex_protocol::plan_tool::UpdatePlanArgs; -use codex_utils_sandbox_summary::create_config_summary_entries; -/// This should be configurable. When used in CI, users may not want to impose -/// a limit so they can see the full transcript. -const MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL: usize = 20; pub(crate) struct EventProcessorWithHumanOutput { - call_id_to_patch: HashMap, - - // To ensure that --color=never is respected, ANSI escapes _must_ be added - // using .style() with one of these fields. If you need a new style, add a - // new field here. bold: Style, - italic: Style, + cyan: Style, dimmed: Style, - + green: Style, + italic: Style, magenta: Style, red: Style, - green: Style, - cyan: Style, yellow: Style, - - /// Whether to include `AgentReasoning` events in the output. show_agent_reasoning: bool, show_raw_agent_reasoning: bool, last_message_path: Option, - last_total_token_usage: Option, final_message: Option, - last_proposed_plan: Option, - progress_active: bool, - progress_last_len: usize, - use_ansi_cursor: bool, - progress_anchor: bool, - progress_done: bool, + final_message_rendered: bool, + emit_final_message_on_shutdown: bool, + last_total_token_usage: Option, } impl EventProcessorWithHumanOutput { pub(crate) fn create_with_ansi( with_ansi: bool, - cursor_ansi: bool, config: &Config, last_message_path: Option, ) -> Self { - let call_id_to_patch = HashMap::new(); + let style = |styled: Style, plain: Style| if with_ansi { styled } else { plain }; + Self { + bold: style(Style::new().bold(), Style::new()), + cyan: style(Style::new().cyan(), Style::new()), + dimmed: style(Style::new().dimmed(), Style::new()), + green: style(Style::new().green(), Style::new()), + italic: style(Style::new().italic(), Style::new()), + magenta: style(Style::new().magenta(), Style::new()), + red: style(Style::new().red(), Style::new()), + yellow: style(Style::new().yellow(), Style::new()), + show_agent_reasoning: !config.hide_agent_reasoning, + show_raw_agent_reasoning: config.show_raw_agent_reasoning, + last_message_path, + final_message: None, + final_message_rendered: false, + emit_final_message_on_shutdown: false, + last_total_token_usage: None, + } + } - if with_ansi { - Self { - call_id_to_patch, - bold: Style::new().bold(), - italic: Style::new().italic(), - dimmed: Style::new().dimmed(), - magenta: Style::new().magenta(), - red: Style::new().red(), - green: Style::new().green(), - cyan: Style::new().cyan(), - yellow: Style::new().yellow(), - show_agent_reasoning: !config.hide_agent_reasoning, - show_raw_agent_reasoning: config.show_raw_agent_reasoning, - last_message_path, - last_total_token_usage: None, - final_message: None, - last_proposed_plan: None, - progress_active: false, - progress_last_len: 0, - use_ansi_cursor: cursor_ansi, - progress_anchor: false, - progress_done: false, + fn render_item_started(&self, item: &ThreadItem) { + match item { + ThreadItem::CommandExecution { command, cwd, .. } => { + eprintln!( + "{}\n{} in {}", + "exec".style(self.italic).style(self.magenta), + command.style(self.bold), + cwd.display() + ); } - } else { - Self { - call_id_to_patch, - bold: Style::new(), - italic: Style::new(), - dimmed: Style::new(), - magenta: Style::new(), - red: Style::new(), - green: Style::new(), - cyan: Style::new(), - yellow: Style::new(), - show_agent_reasoning: !config.hide_agent_reasoning, - show_raw_agent_reasoning: config.show_raw_agent_reasoning, - last_message_path, - last_total_token_usage: None, - final_message: None, - last_proposed_plan: None, - progress_active: false, - progress_last_len: 0, - use_ansi_cursor: cursor_ansi, - progress_anchor: false, - progress_done: false, + ThreadItem::McpToolCall { server, tool, .. } => { + eprintln!( + "{} {} {}", + "mcp:".style(self.bold), + format!("{server}/{tool}").style(self.cyan), + "started".style(self.dimmed) + ); } + ThreadItem::WebSearch { query, .. } => { + eprintln!("{} {}", "web search:".style(self.bold), query); + } + ThreadItem::FileChange { .. } => { + eprintln!("{}", "apply patch".style(self.bold)); + } + ThreadItem::CollabAgentToolCall { tool, .. } => { + eprintln!("{} {:?}", "collab:".style(self.bold), tool); + } + _ => {} + } + } + + fn render_item_completed(&mut self, item: ThreadItem) { + match item { + ThreadItem::AgentMessage { text, .. } => { + eprintln!( + "{}\n{}", + "codex".style(self.italic).style(self.magenta), + text + ); + self.final_message = Some(text); + self.final_message_rendered = true; + } + ThreadItem::Reasoning { + summary, content, .. + } => { + if self.show_agent_reasoning + && let Some(text) = + reasoning_text(&summary, &content, self.show_raw_agent_reasoning) + && !text.trim().is_empty() + { + eprintln!("{}", text.style(self.dimmed)); + } + } + ThreadItem::CommandExecution { + command: _, + aggregated_output, + exit_code, + status, + duration_ms, + .. + } => { + let duration_suffix = duration_ms + .map(|duration_ms| format!(" in {duration_ms}ms")) + .unwrap_or_default(); + match status { + CommandExecutionStatus::Completed => { + eprintln!( + "{}", + format!(" succeeded{duration_suffix}:").style(self.green) + ); + } + CommandExecutionStatus::Failed => { + let exit_code = exit_code.unwrap_or(1); + eprintln!( + "{}", + format!(" exited {exit_code}{duration_suffix}:").style(self.red) + ); + } + CommandExecutionStatus::Declined => { + eprintln!( + "{}", + format!(" declined{duration_suffix}:").style(self.yellow) + ); + } + CommandExecutionStatus::InProgress => { + eprintln!( + "{}", + format!(" in progress{duration_suffix}:").style(self.dimmed) + ); + } + } + if let Some(output) = aggregated_output + && !output.trim().is_empty() + { + eprintln!("{output}"); + } + } + ThreadItem::FileChange { + changes, status, .. + } => { + let status_text = match status { + PatchApplyStatus::Completed => "completed", + PatchApplyStatus::Failed => "failed", + PatchApplyStatus::Declined => "declined", + PatchApplyStatus::InProgress => "in_progress", + }; + eprintln!("{} {}", "patch:".style(self.bold), status_text); + for change in changes { + eprintln!("{}", change.path.style(self.dimmed)); + } + } + ThreadItem::McpToolCall { + server, + tool, + status, + error, + .. + } => { + let status_text = match status { + McpToolCallStatus::Completed => "completed".style(self.green), + McpToolCallStatus::Failed => "failed".style(self.red), + McpToolCallStatus::InProgress => "in_progress".style(self.dimmed), + }; + eprintln!( + "{} {} {}", + "mcp:".style(self.bold), + format!("{server}/{tool}").style(self.cyan), + format!("({status_text})").style(self.dimmed) + ); + if let Some(error) = error { + eprintln!("{}", error.message.style(self.red)); + } + } + ThreadItem::WebSearch { query, .. } => { + eprintln!("{} {}", "web search:".style(self.bold), query); + } + ThreadItem::ContextCompaction { .. } => { + eprintln!("{}", "context compacted".style(self.dimmed)); + } + _ => {} } } } -#[derive(Debug, Deserialize)] -struct AgentJobProgressMessage { - job_id: String, - total_items: usize, - pending_items: usize, - running_items: usize, - completed_items: usize, - failed_items: usize, - eta_seconds: Option, -} - -struct PatchApplyBegin { - start_time: Instant, - auto_approved: bool, -} - -/// Timestamped helper. The timestamp is styled with self.dimmed. -macro_rules! ts_msg { - ($self:ident, $($arg:tt)*) => {{ - eprintln!($($arg)*); - }}; -} - impl EventProcessor for EventProcessorWithHumanOutput { - /// Print a concise summary of the effective configuration that will be used - /// for the session. This mirrors the information shown in the TUI welcome - /// screen. fn print_config_summary( &mut self, config: &Config, @@ -185,979 +216,336 @@ impl EventProcessor for EventProcessorWithHumanOutput { session_configured_event: &SessionConfiguredEvent, ) { const VERSION: &str = env!("CARGO_PKG_VERSION"); - ts_msg!( - self, - "OpenAI Codex v{} (research preview)\n--------", - VERSION - ); - - let mut entries = - create_config_summary_entries(config, session_configured_event.model.as_str()); - entries.push(( - "session id", - session_configured_event.session_id.to_string(), - )); - - for (key, value) in entries { + eprintln!("OpenAI Codex v{VERSION} (research preview)\n--------"); + for (key, value) in config_summary_entries(config, session_configured_event) { eprintln!("{} {}", format!("{key}:").style(self.bold), value); } - eprintln!("--------"); - - // Echo the prompt that will be sent to the agent so it is visible in the - // transcript/logs before any events come in. Note the prompt may have been - // read from stdin, so it may not be visible in the terminal otherwise. - ts_msg!(self, "{}\n{}", "user".style(self.cyan), prompt); + eprintln!("{}\n{}", "user".style(self.cyan), prompt); } - fn process_event(&mut self, event: Event) -> CodexStatus { - let Event { id: _, msg } = event; - if let EventMsg::BackgroundEvent(BackgroundEventEvent { message }) = &msg - && let Some(update) = Self::parse_agent_job_progress(message) - { - self.render_agent_job_progress(update); - return CodexStatus::Running; - } - if self.progress_active && !Self::should_interrupt_progress(&msg) { - return CodexStatus::Running; - } - if !Self::is_silent_event(&msg) { - self.finish_progress_line(); - } - match msg { - EventMsg::Error(ErrorEvent { message, .. }) => { - let prefix = "ERROR:".style(self.red); - ts_msg!(self, "{prefix} {message}"); - } - EventMsg::Warning(WarningEvent { message }) => { - ts_msg!( - self, - "{} {message}", - "warning:".style(self.yellow).style(self.bold) + fn process_server_notification(&mut self, notification: ServerNotification) -> CodexStatus { + match notification { + ServerNotification::ConfigWarning(notification) => { + let details = notification + .details + .map(|details| format!(" ({details})")) + .unwrap_or_default(); + eprintln!( + "{} {}{}", + "warning:".style(self.yellow).style(self.bold), + notification.summary, + details ); + CodexStatus::Running } - EventMsg::GuardianAssessment(_) => {} - EventMsg::ModelReroute(_) => {} - EventMsg::DeprecationNotice(DeprecationNoticeEvent { summary, details }) => { - ts_msg!( - self, - "{} {summary}", - "deprecated:".style(self.magenta).style(self.bold) - ); - if let Some(details) = details { - ts_msg!(self, " {}", details.style(self.dimmed)); - } - } - EventMsg::McpStartupUpdate(update) => { - let status_text = match update.status { - codex_protocol::protocol::McpStartupStatus::Starting => "starting".to_string(), - codex_protocol::protocol::McpStartupStatus::Ready => "ready".to_string(), - codex_protocol::protocol::McpStartupStatus::Cancelled => { - "cancelled".to_string() - } - codex_protocol::protocol::McpStartupStatus::Failed { ref error } => { - format!("failed: {error}") - } - }; - ts_msg!( - self, - "{} {} {}", - "mcp:".style(self.cyan), - update.server, - status_text - ); - } - EventMsg::McpStartupComplete(summary) => { - let mut parts = Vec::new(); - if !summary.ready.is_empty() { - parts.push(format!("ready: {}", summary.ready.join(", "))); - } - if !summary.failed.is_empty() { - let servers: Vec<_> = summary.failed.iter().map(|f| f.server.clone()).collect(); - parts.push(format!("failed: {}", servers.join(", "))); - } - if !summary.cancelled.is_empty() { - parts.push(format!("cancelled: {}", summary.cancelled.join(", "))); - } - let joined = if parts.is_empty() { - "no servers".to_string() - } else { - parts.join("; ") - }; - ts_msg!(self, "{} {}", "mcp startup:".style(self.cyan), joined); - } - EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => { - ts_msg!(self, "{}", message.style(self.dimmed)); - } - EventMsg::StreamError(StreamErrorEvent { - message, - additional_details, - .. - }) => { - let message = match additional_details { - Some(details) if !details.trim().is_empty() => format!("{message} ({details})"), - _ => message, - }; - ts_msg!(self, "{}", message.style(self.dimmed)); - } - EventMsg::TurnStarted(_) => { - // Ignore. - } - EventMsg::ElicitationRequest(ev) => { - ts_msg!( - self, + ServerNotification::Error(notification) => { + eprintln!( "{} {}", - "elicitation request".style(self.magenta), - ev.server_name.style(self.dimmed) - ); - ts_msg!( - self, - "{}", - "auto-cancelling (not supported in exec mode)".style(self.dimmed) + "ERROR:".style(self.red).style(self.bold), + notification.error ); + CodexStatus::Running } - EventMsg::TurnComplete(TurnCompleteEvent { - last_agent_message, .. - }) => { - let last_message = last_agent_message - .as_deref() - .or(self.last_proposed_plan.as_deref()); - if let Some(output_file) = self.last_message_path.as_deref() { - handle_last_message(last_message, output_file); - } - - self.final_message = last_agent_message.or_else(|| self.last_proposed_plan.clone()); - - return CodexStatus::InitiateShutdown; - } - EventMsg::TokenCount(ev) => { - self.last_total_token_usage = ev.info; - } - - EventMsg::AgentReasoningSectionBreak(_) => { - if !self.show_agent_reasoning { - return CodexStatus::Running; - } - eprintln!(); - } - EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => { - if self.show_raw_agent_reasoning { - ts_msg!( - self, - "{}\n{}", - "thinking".style(self.italic).style(self.magenta), - text, - ); - } - } - EventMsg::AgentMessage(AgentMessageEvent { message, .. }) => { - ts_msg!( - self, - "{}\n{}", - "codex".style(self.italic).style(self.magenta), - message, - ); - } - EventMsg::ItemCompleted(ItemCompletedEvent { - item: TurnItem::Plan(item), - .. - }) => { - self.last_proposed_plan = Some(item.text); - } - EventMsg::ExecCommandBegin(ExecCommandBeginEvent { command, cwd, .. }) => { - eprint!( - "{}\n{} in {}", - "exec".style(self.italic).style(self.magenta), - escape_command(&command).style(self.bold), - cwd.to_string_lossy(), - ); - } - EventMsg::ExecCommandEnd(ExecCommandEndEvent { - aggregated_output, - duration, - exit_code, - .. - }) => { - let duration = format!(" in {}", format_duration(duration)); - - let truncated_output = aggregated_output - .lines() - .take(MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL) - .collect::>() - .join("\n"); - match exit_code { - 0 => { - let title = format!(" succeeded{duration}:"); - ts_msg!(self, "{}", title.style(self.green)); - } - _ => { - let title = format!(" exited {exit_code}{duration}:"); - ts_msg!(self, "{}", title.style(self.red)); - } - } - eprintln!("{}", truncated_output.style(self.dimmed)); - } - EventMsg::McpToolCallBegin(McpToolCallBeginEvent { - call_id: _, - invocation, - }) => { - ts_msg!( - self, + ServerNotification::DeprecationNotice(notification) => { + eprintln!( "{} {}", - "tool".style(self.magenta), - format_mcp_invocation(&invocation).style(self.bold), + "deprecated:".style(self.yellow).style(self.bold), + notification.summary ); - } - EventMsg::McpToolCallEnd(tool_call_end_event) => { - let is_success = tool_call_end_event.is_success(); - let McpToolCallEndEvent { - call_id: _, - result, - invocation, - duration, - } = tool_call_end_event; - - let duration = format!(" in {}", format_duration(duration)); - - let status_str = if is_success { "success" } else { "failed" }; - let title_style = if is_success { self.green } else { self.red }; - let title = format!( - "{} {status_str}{duration}:", - format_mcp_invocation(&invocation) - ); - - ts_msg!(self, "{}", title.style(title_style)); - - if let Ok(res) = result { - let val = serde_json::to_value(res) - .unwrap_or_else(|_| serde_json::Value::String("".to_string())); - let pretty = - serde_json::to_string_pretty(&val).unwrap_or_else(|_| val.to_string()); - - for line in pretty.lines().take(MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL) { - eprintln!("{}", line.style(self.dimmed)); - } + if let Some(details) = notification.details { + eprintln!("{}", details.style(self.dimmed)); } + CodexStatus::Running } - EventMsg::WebSearchBegin(_) => { - ts_msg!(self, "🌐 Searching the web..."); - } - EventMsg::WebSearchEnd(WebSearchEndEvent { - call_id: _, - query, - action, - }) => { - let detail = web_search_detail(Some(&action), &query); - if detail.is_empty() { - ts_msg!(self, "🌐 Searched the web"); - } else { - ts_msg!(self, "🌐 Searched: {detail}"); - } - } - EventMsg::ImageGenerationBegin(generated) => { - ts_msg!( - self, + ServerNotification::HookStarted(notification) => { + eprintln!( "{} {}", - "image generation started".style(self.magenta), - generated.call_id + "hook:".style(self.bold), + format!("{:?}", notification.run.event_name).style(self.dimmed) ); + CodexStatus::Running } - EventMsg::ImageGenerationEnd(generated) => { - if !generated.result.is_empty() - && !generated.result.starts_with("data:") - && !generated.result.starts_with("http://") - && !generated.result.starts_with("https://") - && !generated.result.starts_with("file://") - { - ts_msg!( - self, - "{} {} {}", - "generated image".style(self.magenta), - generated.call_id, - generated.result.style(self.dimmed) - ); - } else { - ts_msg!( - self, - "{} {}", - "generated image".style(self.magenta), - generated.call_id - ); - } + ServerNotification::HookCompleted(notification) => { + eprintln!( + "{} {} {:?}", + "hook:".style(self.bold), + format!("{:?}", notification.run.event_name).style(self.dimmed), + notification.run.status + ); + CodexStatus::Running } - EventMsg::PatchApplyBegin(PatchApplyBeginEvent { - call_id, - auto_approved, - changes, - .. - }) => { - // Store metadata so we can calculate duration later when we - // receive the corresponding PatchApplyEnd event. - self.call_id_to_patch.insert( - call_id, - PatchApplyBegin { - start_time: Instant::now(), - auto_approved, - }, + ServerNotification::ItemStarted(notification) => { + self.render_item_started(¬ification.item); + CodexStatus::Running + } + ServerNotification::ItemCompleted(notification) => { + self.render_item_completed(notification.item); + CodexStatus::Running + } + ServerNotification::ModelRerouted(notification) => { + eprintln!( + "{} {} -> {}", + "model rerouted:".style(self.yellow).style(self.bold), + notification.from_model, + notification.to_model ); - - ts_msg!( - self, - "{}", - "file update".style(self.magenta).style(self.italic), - ); - - // Pretty-print the patch summary with colored diff markers so - // it's easy to scan in the terminal output. - for (path, change) in changes.iter() { - match change { - FileChange::Add { content } => { - let header = format!( - "{} {}", - format_file_change(change), - path.to_string_lossy() - ); - eprintln!("{}", header.style(self.magenta)); - for line in content.lines() { - eprintln!("{}", line.style(self.green)); - } - } - FileChange::Delete { content } => { - let header = format!( - "{} {}", - format_file_change(change), - path.to_string_lossy() - ); - eprintln!("{}", header.style(self.magenta)); - for line in content.lines() { - eprintln!("{}", line.style(self.red)); - } - } - FileChange::Update { - unified_diff, - move_path, - } => { - let header = if let Some(dest) = move_path { - format!( - "{} {} -> {}", - format_file_change(change), - path.to_string_lossy(), - dest.to_string_lossy() - ) - } else { - format!("{} {}", format_file_change(change), path.to_string_lossy()) - }; - eprintln!("{}", header.style(self.magenta)); - - // Colorize diff lines. We keep file header lines - // (--- / +++) without extra coloring so they are - // still readable. - for diff_line in unified_diff.lines() { - if diff_line.starts_with('+') && !diff_line.starts_with("+++") { - eprintln!("{}", diff_line.style(self.green)); - } else if diff_line.starts_with('-') - && !diff_line.starts_with("---") - { - eprintln!("{}", diff_line.style(self.red)); - } else { - eprintln!("{diff_line}"); - } - } - } + CodexStatus::Running + } + ServerNotification::ThreadTokenUsageUpdated(notification) => { + self.last_total_token_usage = Some(notification.token_usage); + CodexStatus::Running + } + ServerNotification::TurnCompleted(notification) => match notification.turn.status { + TurnStatus::Completed => { + let rendered_message = self + .final_message_rendered + .then(|| self.final_message.clone()) + .flatten(); + if let Some(final_message) = + final_message_from_turn_items(notification.turn.items.as_slice()) + { + self.final_message_rendered = + rendered_message.as_deref() == Some(final_message.as_str()); + self.final_message = Some(final_message); } + self.emit_final_message_on_shutdown = true; + CodexStatus::InitiateShutdown } - } - EventMsg::PatchApplyEnd(PatchApplyEndEvent { - call_id, - stdout, - stderr, - success, - .. - }) => { - let patch_begin = self.call_id_to_patch.remove(&call_id); - - // Compute duration and summary label similar to exec commands. - let (duration, label) = if let Some(PatchApplyBegin { - start_time, - auto_approved, - }) = patch_begin - { - ( - format!(" in {}", format_elapsed(start_time)), - format!("apply_patch(auto_approved={auto_approved})"), - ) - } else { - (String::new(), format!("apply_patch('{call_id}')")) - }; - - let (exit_code, output, title_style) = if success { - (0, stdout, self.green) - } else { - (1, stderr, self.red) - }; - - let title = format!("{label} exited {exit_code}{duration}:"); - ts_msg!(self, "{}", title.style(title_style)); - for line in output.lines() { - eprintln!("{}", line.style(self.dimmed)); + TurnStatus::Failed => { + self.final_message = None; + self.final_message_rendered = false; + self.emit_final_message_on_shutdown = false; + if let Some(error) = notification.turn.error { + eprintln!("{} {}", "ERROR:".style(self.red).style(self.bold), error); + } + CodexStatus::InitiateShutdown } - } - EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => { - ts_msg!( - self, - "{}", - "file update:".style(self.magenta).style(self.italic) - ); - eprintln!("{unified_diff}"); - } - EventMsg::AgentReasoning(agent_reasoning_event) => { - if self.show_agent_reasoning { - ts_msg!( - self, - "{}\n{}", - "thinking".style(self.italic).style(self.magenta), - agent_reasoning_event.text, - ); + TurnStatus::Interrupted => { + self.final_message = None; + self.final_message_rendered = false; + self.emit_final_message_on_shutdown = false; + eprintln!("{}", "turn interrupted".style(self.dimmed)); + CodexStatus::InitiateShutdown } - } - EventMsg::SessionConfigured(session_configured_event) => { - let SessionConfiguredEvent { - session_id: conversation_id, - model, - .. - } = session_configured_event; - - ts_msg!( - self, - "{} {}", - "codex session".style(self.magenta).style(self.bold), - conversation_id.to_string().style(self.dimmed) - ); - - ts_msg!(self, "model: {}", model); - eprintln!(); - } - EventMsg::PlanUpdate(plan_update_event) => { - let UpdatePlanArgs { explanation, plan } = plan_update_event; - - // Header - ts_msg!(self, "{}", "Plan update".style(self.magenta)); - - // Optional explanation - if let Some(explanation) = explanation - && !explanation.trim().is_empty() - { - ts_msg!(self, "{}", explanation.style(self.italic)); + TurnStatus::InProgress => CodexStatus::Running, + }, + ServerNotification::TurnDiffUpdated(notification) => { + if !notification.diff.trim().is_empty() { + eprintln!("{}", notification.diff); } - - // Pretty-print the plan items with simple status markers. - for item in plan { - match item.status { - StepStatus::Completed => { - ts_msg!(self, " {} {}", "✓".style(self.green), item.step); + CodexStatus::Running + } + ServerNotification::TurnPlanUpdated(notification) => { + if let Some(explanation) = notification.explanation { + eprintln!("{}", explanation.style(self.italic)); + } + for step in notification.plan { + match step.status { + codex_app_server_protocol::TurnPlanStepStatus::Completed => { + eprintln!(" {} {}", "✓".style(self.green), step.step); } - StepStatus::InProgress => { - ts_msg!(self, " {} {}", "→".style(self.cyan), item.step); + codex_app_server_protocol::TurnPlanStepStatus::InProgress => { + eprintln!(" {} {}", "→".style(self.cyan), step.step); } - StepStatus::Pending => { - ts_msg!( - self, + codex_app_server_protocol::TurnPlanStepStatus::Pending => { + eprintln!( " {} {}", "•".style(self.dimmed), - item.step.style(self.dimmed) + step.step.style(self.dimmed) ); } } } + CodexStatus::Running } - EventMsg::ViewImageToolCall(view) => { - ts_msg!( - self, - "{} {}", - "viewed image".style(self.magenta), - view.path.display() - ); - } - EventMsg::TurnAborted(abort_reason) => { - match abort_reason.reason { - TurnAbortReason::Interrupted => { - ts_msg!(self, "task interrupted"); - } - TurnAbortReason::Replaced => { - ts_msg!(self, "task aborted: replaced by a new task"); - } - TurnAbortReason::ReviewEnded => { - ts_msg!(self, "task aborted: review ended"); - } - } - return CodexStatus::InitiateShutdown; - } - EventMsg::ContextCompacted(_) => { - ts_msg!(self, "context compacted"); - } - EventMsg::CollabAgentSpawnBegin(CollabAgentSpawnBeginEvent { - call_id, - sender_thread_id: _, - prompt, - .. - }) => { - ts_msg!( - self, - "{} {}", - "collab".style(self.magenta), - format_collab_invocation("spawn_agent", &call_id, Some(&prompt)) - .style(self.bold) - ); - } - EventMsg::CollabAgentSpawnEnd(CollabAgentSpawnEndEvent { - call_id, - sender_thread_id: _, - new_thread_id, - prompt, - status, - .. - }) => { - let success = new_thread_id.is_some() && !is_collab_status_failure(&status); - let title_style = if success { self.green } else { self.red }; - let title = format!( - "{} {}:", - format_collab_invocation("spawn_agent", &call_id, Some(&prompt)), - format_collab_status(&status) - ); - ts_msg!(self, "{}", title.style(title_style)); - if let Some(new_thread_id) = new_thread_id { - eprintln!(" agent: {}", new_thread_id.to_string().style(self.dimmed)); - } - } - EventMsg::CollabAgentInteractionBegin(CollabAgentInteractionBeginEvent { - call_id, - sender_thread_id: _, - receiver_thread_id, - prompt, - }) => { - ts_msg!( - self, - "{} {}", - "collab".style(self.magenta), - format_collab_invocation("send_input", &call_id, Some(&prompt)) - .style(self.bold) - ); - eprintln!( - " receiver: {}", - receiver_thread_id.to_string().style(self.dimmed) - ); - } - EventMsg::CollabAgentInteractionEnd(CollabAgentInteractionEndEvent { - call_id, - sender_thread_id: _, - receiver_thread_id, - prompt, - status, - .. - }) => { - let success = !is_collab_status_failure(&status); - let title_style = if success { self.green } else { self.red }; - let title = format!( - "{} {}:", - format_collab_invocation("send_input", &call_id, Some(&prompt)), - format_collab_status(&status) - ); - ts_msg!(self, "{}", title.style(title_style)); - eprintln!( - " receiver: {}", - receiver_thread_id.to_string().style(self.dimmed) - ); - } - EventMsg::CollabWaitingBegin(CollabWaitingBeginEvent { - sender_thread_id: _, - receiver_thread_ids, - call_id, - .. - }) => { - ts_msg!( - self, - "{} {}", - "collab".style(self.magenta), - format_collab_invocation("wait", &call_id, /*prompt*/ None).style(self.bold) - ); - eprintln!( - " receivers: {}", - format_receiver_list(&receiver_thread_ids).style(self.dimmed) - ); - } - EventMsg::CollabWaitingEnd(CollabWaitingEndEvent { - sender_thread_id: _, - call_id, - statuses, - .. - }) => { - if statuses.is_empty() { - ts_msg!( - self, - "{} {}:", - format_collab_invocation("wait", &call_id, /*prompt*/ None), - "timed out".style(self.yellow) - ); - return CodexStatus::Running; - } - let success = !statuses.values().any(is_collab_status_failure); - let title_style = if success { self.green } else { self.red }; - let title = format!( - "{} {} agents complete:", - format_collab_invocation("wait", &call_id, /*prompt*/ None), - statuses.len() - ); - ts_msg!(self, "{}", title.style(title_style)); - let mut sorted = statuses - .into_iter() - .map(|(thread_id, status)| (thread_id.to_string(), status)) - .collect::>(); - sorted.sort_by(|(left, _), (right, _)| left.cmp(right)); - for (thread_id, status) in sorted { - eprintln!( - " {} {}", - thread_id.style(self.dimmed), - format_collab_status(&status).style(style_for_agent_status(&status, self)) - ); - } - } - EventMsg::CollabCloseBegin(CollabCloseBeginEvent { - call_id, - sender_thread_id: _, - receiver_thread_id, - }) => { - ts_msg!( - self, - "{} {}", - "collab".style(self.magenta), - format_collab_invocation("close_agent", &call_id, /*prompt*/ None) - .style(self.bold) - ); - eprintln!( - " receiver: {}", - receiver_thread_id.to_string().style(self.dimmed) - ); - } - EventMsg::CollabCloseEnd(CollabCloseEndEvent { - call_id, - sender_thread_id: _, - receiver_thread_id, - status, - .. - }) => { - let success = !is_collab_status_failure(&status); - let title_style = if success { self.green } else { self.red }; - let title = format!( - "{} {}:", - format_collab_invocation("close_agent", &call_id, /*prompt*/ None), - format_collab_status(&status) - ); - ts_msg!(self, "{}", title.style(title_style)); - eprintln!( - " receiver: {}", - receiver_thread_id.to_string().style(self.dimmed) - ); - } - EventMsg::HookStarted(event) => self.render_hook_started(event), - EventMsg::HookCompleted(event) => self.render_hook_completed(event), - EventMsg::ShutdownComplete => return CodexStatus::Shutdown, - EventMsg::ThreadNameUpdated(_) - | EventMsg::ExecApprovalRequest(_) - | EventMsg::ApplyPatchApprovalRequest(_) - | EventMsg::TerminalInteraction(_) - | EventMsg::ExecCommandOutputDelta(_) - | EventMsg::GetHistoryEntryResponse(_) - | EventMsg::McpListToolsResponse(_) - | EventMsg::ListCustomPromptsResponse(_) - | EventMsg::ListSkillsResponse(_) - | EventMsg::RawResponseItem(_) - | EventMsg::UserMessage(_) - | EventMsg::EnteredReviewMode(_) - | EventMsg::ExitedReviewMode(_) - | EventMsg::AgentMessageDelta(_) - | EventMsg::AgentReasoningDelta(_) - | EventMsg::AgentReasoningRawContentDelta(_) - | EventMsg::ItemStarted(_) - | EventMsg::ItemCompleted(_) - | EventMsg::AgentMessageContentDelta(_) - | EventMsg::PlanDelta(_) - | EventMsg::ReasoningContentDelta(_) - | EventMsg::ReasoningRawContentDelta(_) - | EventMsg::SkillsUpdateAvailable - | EventMsg::UndoCompleted(_) - | EventMsg::UndoStarted(_) - | EventMsg::ThreadRolledBack(_) - | EventMsg::RequestUserInput(_) - | EventMsg::RequestPermissions(_) - | EventMsg::CollabResumeBegin(_) - | EventMsg::CollabResumeEnd(_) - | EventMsg::RealtimeConversationStarted(_) - | EventMsg::RealtimeConversationRealtime(_) - | EventMsg::RealtimeConversationClosed(_) - | EventMsg::DynamicToolCallRequest(_) - | EventMsg::DynamicToolCallResponse(_) => {} + ServerNotification::TurnStarted(_) => CodexStatus::Running, + _ => CodexStatus::Running, } + } + + fn process_warning(&mut self, message: String) -> CodexStatus { + eprintln!( + "{} {message}", + "warning:".style(self.yellow).style(self.bold) + ); CodexStatus::Running } fn print_final_output(&mut self) { - self.finish_progress_line(); - if let Some(usage_info) = &self.last_total_token_usage { + if self.emit_final_message_on_shutdown + && let Some(path) = self.last_message_path.as_deref() + { + handle_last_message(self.final_message.as_deref(), path); + } + + if let Some(usage) = &self.last_total_token_usage { eprintln!( "{}\n{}", - "tokens used".style(self.magenta).style(self.italic), - format_with_separators(usage_info.total_token_usage.blended_total()) + "tokens used".style(self.dimmed), + format_with_separators(blended_total(usage)) ); } - // In interactive terminals we already emitted the final assistant - // message on stderr during event processing. Preserve stdout emission - // only for non-interactive use so pipes and scripts still receive the - // final message. #[allow(clippy::print_stdout)] if should_print_final_message_to_stdout( - self.final_message.as_deref(), + self.emit_final_message_on_shutdown + .then_some(self.final_message.as_deref()) + .flatten(), std::io::stdout().is_terminal(), std::io::stderr().is_terminal(), - ) && let Some(message) = &self.final_message + ) && let Some(message) = self.final_message.as_deref() { - if message.ends_with('\n') { - print!("{message}"); - } else { - println!("{message}"); - } + println!("{message}"); + } else if should_print_final_message_to_tty( + self.emit_final_message_on_shutdown + .then_some(self.final_message.as_deref()) + .flatten(), + self.final_message_rendered, + std::io::stdout().is_terminal(), + std::io::stderr().is_terminal(), + ) && let Some(message) = self.final_message.as_deref() + { + eprintln!( + "{}\n{}", + "codex".style(self.italic).style(self.magenta), + message + ); } } } -impl EventProcessorWithHumanOutput { - fn parse_agent_job_progress(message: &str) -> Option { - let payload = message.strip_prefix("agent_job_progress:")?; - serde_json::from_str::(payload).ok() +fn config_summary_entries( + config: &Config, + session_configured_event: &SessionConfiguredEvent, +) -> Vec<(&'static str, String)> { + let mut entries = vec![ + ("workdir", config.cwd.display().to_string()), + ("model", session_configured_event.model.clone()), + ( + "provider", + session_configured_event.model_provider_id.clone(), + ), + ( + "approval", + config.permissions.approval_policy.value().to_string(), + ), + ( + "sandbox", + summarize_sandbox_policy(config.permissions.sandbox_policy.get()), + ), + ]; + if config.model_provider.wire_api == WireApi::Responses { + entries.push(( + "reasoning effort", + config + .model_reasoning_effort + .map(|effort| effort.to_string()) + .unwrap_or_else(|| "none".to_string()), + )); + entries.push(( + "reasoning summaries", + config + .model_reasoning_summary + .map(|summary| summary.to_string()) + .unwrap_or_else(|| "none".to_string()), + )); } + entries.push(( + "session id", + session_configured_event.session_id.to_string(), + )); + entries +} - fn render_hook_started(&self, event: HookStartedEvent) { - if !Self::should_print_hook_started(&event) { - return; +fn summarize_sandbox_policy(sandbox_policy: &SandboxPolicy) -> String { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(), + SandboxPolicy::ReadOnly { network_access, .. } => { + let mut summary = "read-only".to_string(); + if *network_access { + summary.push_str(" (network access enabled)"); + } + summary } - let event_name = Self::hook_event_name(event.run.event_name); - if let Some(status_message) = event.run.status_message - && !status_message.trim().is_empty() - { - ts_msg!( - self, - "{} {}: {}", - "hook".style(self.magenta), - event_name, - status_message + SandboxPolicy::ExternalSandbox { network_access } => { + let mut summary = "external-sandbox".to_string(); + if matches!( + network_access, + codex_protocol::protocol::NetworkAccess::Enabled + ) { + summary.push_str(" (network access enabled)"); + } + summary + } + SandboxPolicy::WorkspaceWrite { + writable_roots, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + read_only_access: _, + } => { + let mut summary = "workspace-write".to_string(); + let mut writable_entries = vec!["workdir".to_string()]; + if !*exclude_slash_tmp { + writable_entries.push("/tmp".to_string()); + } + if !*exclude_tmpdir_env_var { + writable_entries.push("$TMPDIR".to_string()); + } + writable_entries.extend( + writable_roots + .iter() + .map(|path| path.to_string_lossy().to_string()), ); - } - } - - fn render_hook_completed(&self, event: HookCompletedEvent) { - if !Self::should_print_hook_completed(&event) { - return; - } - - let event_name = Self::hook_event_name(event.run.event_name); - let status = Self::hook_status_name(event.run.status); - ts_msg!( - self, - "{} {} ({status})", - "hook".style(self.magenta), - event_name - ); - - for entry in event.run.entries { - let prefix = Self::hook_entry_prefix(entry.kind); - eprintln!(" {prefix} {}", entry.text); - } - } - - fn should_print_hook_started(event: &HookStartedEvent) -> bool { - event - .run - .status_message - .as_deref() - .is_some_and(|status_message| !status_message.trim().is_empty()) - } - - fn should_print_hook_completed(event: &HookCompletedEvent) -> bool { - event.run.status != HookRunStatus::Completed || !event.run.entries.is_empty() - } - - fn hook_event_name(event_name: HookEventName) -> &'static str { - match event_name { - HookEventName::PreToolUse => "PreToolUse", - HookEventName::SessionStart => "SessionStart", - HookEventName::UserPromptSubmit => "UserPromptSubmit", - HookEventName::Stop => "Stop", - } - } - - fn hook_status_name(status: HookRunStatus) -> &'static str { - match status { - HookRunStatus::Running => "running", - HookRunStatus::Completed => "completed", - HookRunStatus::Failed => "failed", - HookRunStatus::Blocked => "blocked", - HookRunStatus::Stopped => "stopped", - } - } - - fn hook_entry_prefix(kind: HookOutputEntryKind) -> &'static str { - match kind { - HookOutputEntryKind::Warning => "warning:", - HookOutputEntryKind::Stop => "stop:", - HookOutputEntryKind::Feedback => "feedback:", - HookOutputEntryKind::Context => "context:", - HookOutputEntryKind::Error => "error:", - } - } - - fn is_silent_event(msg: &EventMsg) -> bool { - match msg { - EventMsg::HookStarted(event) => !Self::should_print_hook_started(event), - EventMsg::HookCompleted(event) => !Self::should_print_hook_completed(event), - _ => matches!( - msg, - EventMsg::ThreadNameUpdated(_) - | EventMsg::TokenCount(_) - | EventMsg::TurnStarted(_) - | EventMsg::ExecApprovalRequest(_) - | EventMsg::ApplyPatchApprovalRequest(_) - | EventMsg::TerminalInteraction(_) - | EventMsg::ExecCommandOutputDelta(_) - | EventMsg::GetHistoryEntryResponse(_) - | EventMsg::McpListToolsResponse(_) - | EventMsg::ListCustomPromptsResponse(_) - | EventMsg::ListSkillsResponse(_) - | EventMsg::RawResponseItem(_) - | EventMsg::UserMessage(_) - | EventMsg::EnteredReviewMode(_) - | EventMsg::ExitedReviewMode(_) - | EventMsg::AgentMessageDelta(_) - | EventMsg::AgentReasoningDelta(_) - | EventMsg::AgentReasoningRawContentDelta(_) - | EventMsg::ItemStarted(_) - | EventMsg::ItemCompleted(_) - | EventMsg::AgentMessageContentDelta(_) - | EventMsg::PlanDelta(_) - | EventMsg::ReasoningContentDelta(_) - | EventMsg::ReasoningRawContentDelta(_) - | EventMsg::SkillsUpdateAvailable - | EventMsg::UndoCompleted(_) - | EventMsg::UndoStarted(_) - | EventMsg::ThreadRolledBack(_) - | EventMsg::RequestUserInput(_) - | EventMsg::RequestPermissions(_) - | EventMsg::DynamicToolCallRequest(_) - | EventMsg::DynamicToolCallResponse(_) - | EventMsg::GuardianAssessment(_) - ), - } - } - - fn should_interrupt_progress(msg: &EventMsg) -> bool { - if let EventMsg::HookCompleted(event) = msg { - return Self::should_print_hook_completed(event); - } - matches!( - msg, - EventMsg::Error(_) - | EventMsg::Warning(_) - | EventMsg::GuardianAssessment(_) - | EventMsg::DeprecationNotice(_) - | EventMsg::StreamError(_) - | EventMsg::TurnComplete(_) - | EventMsg::ShutdownComplete - ) - } - - fn finish_progress_line(&mut self) { - if self.progress_active { - self.progress_active = false; - self.progress_last_len = 0; - self.progress_done = false; - if self.use_ansi_cursor { - if self.progress_anchor { - eprintln!("\u{1b}[1A\u{1b}[1G\u{1b}[2K"); - } else { - eprintln!("\u{1b}[1G\u{1b}[2K"); - } - } else { - eprintln!(); + summary.push_str(&format!(" [{}]", writable_entries.join(", "))); + if *network_access { + summary.push_str(" (network access enabled)"); } - self.progress_anchor = false; + summary } } +} - fn render_agent_job_progress(&mut self, update: AgentJobProgressMessage) { - let total = update.total_items.max(1); - let processed = update.completed_items + update.failed_items; - let percent = (processed as f64 / total as f64 * 100.0).round() as i64; - let job_label = update.job_id.chars().take(8).collect::(); - let eta = update - .eta_seconds - .map(|secs| format_duration(Duration::from_secs(secs))) - .unwrap_or_else(|| "--".to_string()); - let columns = std::env::var("COLUMNS") - .ok() - .and_then(|value| value.parse::().ok()) - .filter(|value| *value > 0); - let line = format_agent_job_progress_line( - columns, - job_label.as_str(), - AgentJobProgressStats { - processed, - total, - percent, - failed: update.failed_items, - running: update.running_items, - pending: update.pending_items, - }, - eta.as_str(), - ); - let done = processed >= update.total_items; - if !self.use_ansi_cursor { - eprintln!("{line}"); - if done { - self.progress_active = false; - self.progress_last_len = 0; - } - return; - } - if done && self.progress_done { - return; - } - if !self.progress_active { - eprintln!(); - self.progress_anchor = true; - self.progress_done = false; - } - let mut output = String::new(); - if self.progress_anchor { - output.push_str("\u{1b}[1A\u{1b}[1G\u{1b}[2K"); - } else { - output.push_str("\u{1b}[1G\u{1b}[2K"); - } - output.push_str(&line); - if done { - output.push('\n'); - eprint!("{output}"); - self.progress_active = false; - self.progress_last_len = 0; - self.progress_anchor = false; - self.progress_done = true; - return; - } - eprint!("{output}"); - let _ = std::io::stderr().flush(); - self.progress_active = true; - self.progress_last_len = line.len(); +fn reasoning_text( + summary: &[String], + content: &[String], + show_raw_agent_reasoning: bool, +) -> Option { + let entries = if show_raw_agent_reasoning && !content.is_empty() { + content + } else { + summary + }; + if entries.is_empty() { + None + } else { + Some(entries.join("\n")) } } +fn final_message_from_turn_items(items: &[ThreadItem]) -> Option { + items + .iter() + .rev() + .find_map(|item| match item { + ThreadItem::AgentMessage { text, .. } => Some(text.clone()), + _ => None, + }) + .or_else(|| { + items.iter().rev().find_map(|item| match item { + ThreadItem::Plan { text, .. } => Some(text.clone()), + _ => None, + }) + }) +} + +fn blended_total(usage: &ThreadTokenUsage) -> i64 { + let cached_input = usage.total.cached_input_tokens.max(0); + let non_cached_input = (usage.total.input_tokens - cached_input).max(0); + (non_cached_input + usage.total.output_tokens.max(0)).max(0) +} + fn should_print_final_message_to_stdout( final_message: Option<&str>, stdout_is_terminal: bool, @@ -1166,293 +554,357 @@ fn should_print_final_message_to_stdout( final_message.is_some() && !(stdout_is_terminal && stderr_is_terminal) } -struct AgentJobProgressStats { - processed: usize, - total: usize, - percent: i64, - failed: usize, - running: usize, - pending: usize, -} - -fn format_agent_job_progress_line( - columns: Option, - job_label: &str, - stats: AgentJobProgressStats, - eta: &str, -) -> String { - let rest = format!( - "{processed}/{total} {percent}% f{failed} r{running} p{pending} eta {eta}", - processed = stats.processed, - total = stats.total, - percent = stats.percent, - failed = stats.failed, - running = stats.running, - pending = stats.pending - ); - let prefix = format!("job {job_label}"); - let base_len = prefix.len() + rest.len() + 4; - let mut bar_width = columns - .and_then(|columns| columns.checked_sub(base_len)) - .filter(|available| *available > 0) - .unwrap_or(20usize); - let with_bar = |width: usize| { - let filled = ((stats.processed as f64 / stats.total as f64) * width as f64) - .round() - .clamp(0.0, width as f64) as usize; - let mut bar = "#".repeat(filled); - bar.push_str(&"-".repeat(width - filled)); - format!("{prefix} [{bar}] {rest}") - }; - let mut line = with_bar(bar_width); - if let Some(columns) = columns - && line.len() > columns - { - let min_line = format!("{prefix} {rest}"); - if min_line.len() > columns { - let mut truncated = min_line; - if columns > 2 && truncated.len() > columns { - truncated.truncate(columns - 2); - truncated.push_str(".."); - } - return truncated; - } - let available = columns.saturating_sub(base_len); - if available == 0 { - return min_line; - } - bar_width = available.min(bar_width).max(1); - line = with_bar(bar_width); - } - line -} - -fn escape_command(command: &[String]) -> String { - try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" ")) -} - -fn format_file_change(change: &FileChange) -> &'static str { - match change { - FileChange::Add { .. } => "A", - FileChange::Delete { .. } => "D", - FileChange::Update { - move_path: Some(_), .. - } => "R", - FileChange::Update { - move_path: None, .. - } => "M", - } -} - -fn format_collab_invocation(tool: &str, call_id: &str, prompt: Option<&str>) -> String { - let prompt = prompt - .map(str::trim) - .filter(|prompt| !prompt.is_empty()) - .map(|prompt| truncate_preview(prompt, /*max_chars*/ 120)); - match prompt { - Some(prompt) => format!("{tool}({call_id}, prompt=\"{prompt}\")"), - None => format!("{tool}({call_id})"), - } -} - -fn format_collab_status(status: &AgentStatus) -> String { - match status { - AgentStatus::PendingInit => "pending init".to_string(), - AgentStatus::Running => "running".to_string(), - AgentStatus::Interrupted => "interrupted".to_string(), - AgentStatus::Completed(Some(message)) => { - let preview = truncate_preview(message.trim(), /*max_chars*/ 120); - if preview.is_empty() { - "completed".to_string() - } else { - format!("completed: \"{preview}\"") - } - } - AgentStatus::Completed(None) => "completed".to_string(), - AgentStatus::Errored(message) => { - let preview = truncate_preview(message.trim(), /*max_chars*/ 120); - if preview.is_empty() { - "errored".to_string() - } else { - format!("errored: \"{preview}\"") - } - } - AgentStatus::Shutdown => "shutdown".to_string(), - AgentStatus::NotFound => "not found".to_string(), - } -} - -fn style_for_agent_status( - status: &AgentStatus, - processor: &EventProcessorWithHumanOutput, -) -> Style { - match status { - AgentStatus::PendingInit | AgentStatus::Shutdown => processor.dimmed, - AgentStatus::Running => processor.cyan, - AgentStatus::Interrupted => processor.yellow, - AgentStatus::Completed(_) => processor.green, - AgentStatus::Errored(_) | AgentStatus::NotFound => processor.red, - } -} - -fn is_collab_status_failure(status: &AgentStatus) -> bool { - matches!(status, AgentStatus::Errored(_) | AgentStatus::NotFound) -} - -fn format_receiver_list(ids: &[codex_protocol::ThreadId]) -> String { - if ids.is_empty() { - return "none".to_string(); - } - ids.iter() - .map(ToString::to_string) - .collect::>() - .join(", ") -} - -fn truncate_preview(text: &str, max_chars: usize) -> String { - if text.chars().count() <= max_chars { - return text.to_string(); - } - - let preview = text.chars().take(max_chars).collect::(); - format!("{preview}…") -} - -fn format_mcp_invocation(invocation: &McpInvocation) -> String { - // Build fully-qualified tool name: server.tool - let fq_tool_name = format!("{}.{}", invocation.server, invocation.tool); - - // Format arguments as compact JSON so they fit on one line. - let args_str = invocation - .arguments - .as_ref() - .map(|v: &serde_json::Value| serde_json::to_string(v).unwrap_or_else(|_| v.to_string())) - .unwrap_or_default(); - - if args_str.is_empty() { - format!("{fq_tool_name}()") - } else { - format!("{fq_tool_name}({args_str})") - } +fn should_print_final_message_to_tty( + final_message: Option<&str>, + final_message_rendered: bool, + stdout_is_terminal: bool, + stderr_is_terminal: bool, +) -> bool { + final_message.is_some() && !final_message_rendered && stdout_is_terminal && stderr_is_terminal } #[cfg(test)] mod tests { - use std::path::PathBuf; - - use codex_protocol::protocol::EventMsg; - use codex_protocol::protocol::HookCompletedEvent; - use codex_protocol::protocol::HookEventName; - use codex_protocol::protocol::HookExecutionMode; - use codex_protocol::protocol::HookHandlerType; - use codex_protocol::protocol::HookOutputEntry; - use codex_protocol::protocol::HookRunStatus; - use codex_protocol::protocol::HookRunSummary; - use codex_protocol::protocol::HookScope; - use codex_protocol::protocol::HookStartedEvent; + use codex_app_server_protocol::ThreadItem; + use codex_app_server_protocol::Turn; + use codex_app_server_protocol::TurnStatus; + use owo_colors::Style; use super::EventProcessorWithHumanOutput; + use super::final_message_from_turn_items; + use super::reasoning_text; use super::should_print_final_message_to_stdout; - use pretty_assertions::assert_eq; + use super::should_print_final_message_to_tty; + use crate::event_processor::EventProcessor; + use codex_app_server_protocol::ServerNotification; #[test] fn suppresses_final_stdout_message_when_both_streams_are_terminals() { - assert_eq!( - should_print_final_message_to_stdout(Some("hello"), true, true), - false - ); + assert!(!should_print_final_message_to_stdout( + Some("hello"), + true, + true + )); } #[test] fn prints_final_stdout_message_when_stdout_is_not_terminal() { - assert_eq!( - should_print_final_message_to_stdout(Some("hello"), false, true), + assert!(should_print_final_message_to_stdout( + Some("hello"), + false, true - ); + )); } #[test] fn prints_final_stdout_message_when_stderr_is_not_terminal() { - assert_eq!( - should_print_final_message_to_stdout(Some("hello"), true, false), - true - ); - } - - #[test] - fn does_not_print_when_message_is_missing() { - assert_eq!( - should_print_final_message_to_stdout(None, false, false), + assert!(should_print_final_message_to_stdout( + Some("hello"), + true, false + )); + } + + #[test] + fn suppresses_final_stdout_message_when_missing() { + assert!(!should_print_final_message_to_stdout(None, false, false)); + } + + #[test] + fn prints_final_tty_message_when_not_yet_rendered() { + assert!(should_print_final_message_to_tty( + Some("hello"), + false, + true, + true + )); + } + + #[test] + fn suppresses_final_tty_message_when_already_rendered() { + assert!(!should_print_final_message_to_tty( + Some("hello"), + true, + true, + true + )); + } + + #[test] + fn reasoning_text_prefers_summary_when_raw_reasoning_is_hidden() { + let text = reasoning_text( + &["summary".to_string()], + &["raw".to_string()], + /*show_raw_agent_reasoning*/ false, ); + + assert_eq!(text.as_deref(), Some("summary")); } #[test] - fn hook_started_with_status_message_is_not_silent() { - let event = HookStartedEvent { - turn_id: Some("turn-1".to_string()), - run: hook_run( - HookRunStatus::Running, - Some("running hook"), - Vec::new(), - HookEventName::Stop, - ), - }; + fn reasoning_text_uses_raw_content_when_enabled() { + let text = reasoning_text( + &["summary".to_string()], + &["raw".to_string()], + /*show_raw_agent_reasoning*/ true, + ); - assert!(!EventProcessorWithHumanOutput::is_silent_event( - &EventMsg::HookStarted(event) - )); + assert_eq!(text.as_deref(), Some("raw")); } #[test] - fn hook_completed_failure_interrupts_progress() { - let event = HookCompletedEvent { - turn_id: Some("turn-1".to_string()), - run: hook_run(HookRunStatus::Failed, None, Vec::new(), HookEventName::Stop), - }; + fn final_message_from_turn_items_uses_latest_agent_message() { + let message = final_message_from_turn_items(&[ + ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "first".to_string(), + phase: None, + memory_citation: None, + }, + ThreadItem::Plan { + id: "plan-1".to_string(), + text: "plan".to_string(), + }, + ThreadItem::AgentMessage { + id: "msg-2".to_string(), + text: "second".to_string(), + phase: None, + memory_citation: None, + }, + ]); - assert!(EventProcessorWithHumanOutput::should_interrupt_progress( - &EventMsg::HookCompleted(event) - )); + assert_eq!(message.as_deref(), Some("second")); } #[test] - fn hook_completed_success_without_entries_stays_silent() { - let event = HookCompletedEvent { - turn_id: Some("turn-1".to_string()), - run: hook_run( - HookRunStatus::Completed, - None, - Vec::new(), - HookEventName::Stop, - ), - }; + fn final_message_from_turn_items_falls_back_to_latest_plan() { + let message = final_message_from_turn_items(&[ + ThreadItem::Reasoning { + id: "reasoning-1".to_string(), + summary: vec!["inspect".to_string()], + content: Vec::new(), + }, + ThreadItem::Plan { + id: "plan-1".to_string(), + text: "first plan".to_string(), + }, + ThreadItem::Plan { + id: "plan-2".to_string(), + text: "final plan".to_string(), + }, + ]); - assert!(EventProcessorWithHumanOutput::is_silent_event( - &EventMsg::HookCompleted(event) - )); + assert_eq!(message.as_deref(), Some("final plan")); } - fn hook_run( - status: HookRunStatus, - status_message: Option<&str>, - entries: Vec, - event_name: HookEventName, - ) -> HookRunSummary { - HookRunSummary { - id: "hook-run-1".to_string(), - event_name, - handler_type: HookHandlerType::Command, - execution_mode: HookExecutionMode::Sync, - scope: HookScope::Turn, - source_path: PathBuf::from("/tmp/hooks.json"), - display_order: 0, + #[test] + fn turn_completed_recovers_final_message_from_turn_items() { + let mut processor = EventProcessorWithHumanOutput { + bold: Style::new(), + cyan: Style::new(), + dimmed: Style::new(), + green: Style::new(), + italic: Style::new(), + magenta: Style::new(), + red: Style::new(), + yellow: Style::new(), + show_agent_reasoning: true, + show_raw_agent_reasoning: false, + last_message_path: None, + final_message: None, + final_message_rendered: false, + emit_final_message_on_shutdown: false, + last_total_token_usage: None, + }; + + let status = processor.process_server_notification(ServerNotification::TurnCompleted( + codex_app_server_protocol::TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: vec![ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "final answer".to_string(), + phase: None, + memory_citation: None, + }], + status: TurnStatus::Completed, + error: None, + }, + }, + )); + + assert_eq!( status, - status_message: status_message.map(ToOwned::to_owned), - started_at: 0, - completed_at: Some(1), - duration_ms: Some(1), - entries, - } + crate::event_processor::CodexStatus::InitiateShutdown + ); + assert_eq!(processor.final_message.as_deref(), Some("final answer")); + } + + #[test] + fn turn_completed_overwrites_stale_final_message_from_turn_items() { + let mut processor = EventProcessorWithHumanOutput { + bold: Style::new(), + cyan: Style::new(), + dimmed: Style::new(), + green: Style::new(), + italic: Style::new(), + magenta: Style::new(), + red: Style::new(), + yellow: Style::new(), + show_agent_reasoning: true, + show_raw_agent_reasoning: false, + last_message_path: None, + final_message: Some("stale answer".to_string()), + final_message_rendered: true, + emit_final_message_on_shutdown: false, + last_total_token_usage: None, + }; + + let status = processor.process_server_notification(ServerNotification::TurnCompleted( + codex_app_server_protocol::TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: vec![ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "final answer".to_string(), + phase: None, + memory_citation: None, + }], + status: TurnStatus::Completed, + error: None, + }, + }, + )); + + assert_eq!( + status, + crate::event_processor::CodexStatus::InitiateShutdown + ); + assert_eq!(processor.final_message.as_deref(), Some("final answer")); + assert!(!processor.final_message_rendered); + } + + #[test] + fn turn_completed_preserves_streamed_final_message_when_turn_items_are_empty() { + let mut processor = EventProcessorWithHumanOutput { + bold: Style::new(), + cyan: Style::new(), + dimmed: Style::new(), + green: Style::new(), + italic: Style::new(), + magenta: Style::new(), + red: Style::new(), + yellow: Style::new(), + show_agent_reasoning: true, + show_raw_agent_reasoning: false, + last_message_path: None, + final_message: Some("streamed answer".to_string()), + final_message_rendered: false, + emit_final_message_on_shutdown: false, + last_total_token_usage: None, + }; + + let status = processor.process_server_notification(ServerNotification::TurnCompleted( + codex_app_server_protocol::TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::Completed, + error: None, + }, + }, + )); + + assert_eq!( + status, + crate::event_processor::CodexStatus::InitiateShutdown + ); + assert_eq!(processor.final_message.as_deref(), Some("streamed answer")); + assert!(processor.emit_final_message_on_shutdown); + } + + #[test] + fn turn_failed_clears_stale_final_message() { + let mut processor = EventProcessorWithHumanOutput { + bold: Style::new(), + cyan: Style::new(), + dimmed: Style::new(), + green: Style::new(), + italic: Style::new(), + magenta: Style::new(), + red: Style::new(), + yellow: Style::new(), + show_agent_reasoning: true, + show_raw_agent_reasoning: false, + last_message_path: None, + final_message: Some("partial answer".to_string()), + final_message_rendered: true, + emit_final_message_on_shutdown: true, + last_total_token_usage: None, + }; + + let status = processor.process_server_notification(ServerNotification::TurnCompleted( + codex_app_server_protocol::TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::Failed, + error: None, + }, + }, + )); + + assert_eq!( + status, + crate::event_processor::CodexStatus::InitiateShutdown + ); + assert_eq!(processor.final_message, None); + assert!(!processor.final_message_rendered); + assert!(!processor.emit_final_message_on_shutdown); + } + + #[test] + fn turn_interrupted_clears_stale_final_message() { + let mut processor = EventProcessorWithHumanOutput { + bold: Style::new(), + cyan: Style::new(), + dimmed: Style::new(), + green: Style::new(), + italic: Style::new(), + magenta: Style::new(), + red: Style::new(), + yellow: Style::new(), + show_agent_reasoning: true, + show_raw_agent_reasoning: false, + last_message_path: None, + final_message: Some("partial answer".to_string()), + final_message_rendered: true, + emit_final_message_on_shutdown: true, + last_total_token_usage: None, + }; + + let status = processor.process_server_notification(ServerNotification::TurnCompleted( + codex_app_server_protocol::TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::Interrupted, + error: None, + }, + }, + )); + + assert_eq!( + status, + crate::event_processor::CodexStatus::InitiateShutdown + ); + assert_eq!(processor.final_message, None); + assert!(!processor.final_message_rendered); + assert!(!processor.emit_final_message_on_shutdown); } } diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs index 33f44add7..1a085d93d 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -1,8 +1,24 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; -use crate::event_processor::CodexStatus; +use codex_app_server_protocol::CollabAgentTool; +use codex_app_server_protocol::CollabAgentToolCallStatus; +use codex_app_server_protocol::CommandExecutionStatus; +use codex_app_server_protocol::McpToolCallStatus; +use codex_app_server_protocol::PatchApplyStatus; +use codex_app_server_protocol::PatchChangeKind; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::TurnStatus; +use codex_core::config::Config; +use codex_protocol::models::WebSearchAction; +use codex_protocol::protocol::SessionConfiguredEvent; +use serde_json::json; + +pub use crate::event_processor::CodexStatus; use crate::event_processor::EventProcessor; use crate::event_processor::handle_last_message; use crate::exec_events::AgentMessageItem; @@ -12,7 +28,7 @@ use crate::exec_events::CollabTool; use crate::exec_events::CollabToolCallItem; use crate::exec_events::CollabToolCallStatus; use crate::exec_events::CommandExecutionItem; -use crate::exec_events::CommandExecutionStatus; +use crate::exec_events::CommandExecutionStatus as ExecCommandExecutionStatus; use crate::exec_events::ErrorItem; use crate::exec_events::FileChangeItem; use crate::exec_events::FileUpdateChange; @@ -22,13 +38,13 @@ use crate::exec_events::ItemUpdatedEvent; use crate::exec_events::McpToolCallItem; use crate::exec_events::McpToolCallItemError; use crate::exec_events::McpToolCallItemResult; -use crate::exec_events::McpToolCallStatus; -use crate::exec_events::PatchApplyStatus; -use crate::exec_events::PatchChangeKind; +use crate::exec_events::McpToolCallStatus as ExecMcpToolCallStatus; +use crate::exec_events::PatchApplyStatus as ExecPatchApplyStatus; +use crate::exec_events::PatchChangeKind as ExecPatchChangeKind; use crate::exec_events::ReasoningItem; use crate::exec_events::ThreadErrorEvent; use crate::exec_events::ThreadEvent; -use crate::exec_events::ThreadItem; +use crate::exec_events::ThreadItem as ExecThreadItem; use crate::exec_events::ThreadItemDetails; use crate::exec_events::ThreadStartedEvent; use crate::exec_events::TodoItem; @@ -38,45 +54,16 @@ use crate::exec_events::TurnFailedEvent; use crate::exec_events::TurnStartedEvent; use crate::exec_events::Usage; use crate::exec_events::WebSearchItem; -use codex_core::config::Config; -use codex_protocol::models::WebSearchAction; -use codex_protocol::plan_tool::StepStatus; -use codex_protocol::plan_tool::UpdatePlanArgs; -use codex_protocol::protocol; -use codex_protocol::protocol::AgentStatus as CoreAgentStatus; -use codex_protocol::protocol::CollabAgentInteractionBeginEvent; -use codex_protocol::protocol::CollabAgentInteractionEndEvent; -use codex_protocol::protocol::CollabAgentSpawnBeginEvent; -use codex_protocol::protocol::CollabAgentSpawnEndEvent; -use codex_protocol::protocol::CollabCloseBeginEvent; -use codex_protocol::protocol::CollabCloseEndEvent; -use codex_protocol::protocol::CollabWaitingBeginEvent; -use codex_protocol::protocol::CollabWaitingEndEvent; -use serde_json::Value as JsonValue; -use tracing::error; -use tracing::warn; pub struct EventProcessorWithJsonOutput { last_message_path: Option, - last_proposed_plan: Option, - next_event_id: AtomicU64, - // Tracks running commands by call_id, including the associated item id. - running_commands: HashMap, - running_patch_applies: HashMap, - // Tracks the todo list for the current turn (at most one per turn). + next_item_id: AtomicU64, + raw_to_exec_item_id: HashMap, running_todo_list: Option, - last_total_token_usage: Option, - running_mcp_tool_calls: HashMap, - running_collab_tool_calls: HashMap, - running_web_search_calls: HashMap, + last_total_token_usage: Option, last_critical_error: Option, -} - -#[derive(Debug, Clone)] -struct RunningCommand { - command: String, - item_id: String, - aggregated_output: String, + final_message: Option, + emit_final_message_on_shutdown: bool, } #[derive(Debug, Clone)] @@ -85,800 +72,608 @@ struct RunningTodoList { items: Vec, } -#[derive(Debug, Clone)] -struct RunningMcpToolCall { - server: String, - tool: String, - item_id: String, - arguments: JsonValue, -} - -#[derive(Debug, Clone)] -struct RunningCollabToolCall { - tool: CollabTool, - item_id: String, +#[derive(Debug, PartialEq)] +pub struct CollectedThreadEvents { + pub events: Vec, + pub status: CodexStatus, } impl EventProcessorWithJsonOutput { pub fn new(last_message_path: Option) -> Self { Self { last_message_path, - last_proposed_plan: None, - next_event_id: AtomicU64::new(0), - running_commands: HashMap::new(), - running_patch_applies: HashMap::new(), + next_item_id: AtomicU64::new(0), + raw_to_exec_item_id: HashMap::new(), running_todo_list: None, last_total_token_usage: None, - running_mcp_tool_calls: HashMap::new(), - running_collab_tool_calls: HashMap::new(), - running_web_search_calls: HashMap::new(), last_critical_error: None, + final_message: None, + emit_final_message_on_shutdown: false, } } - pub fn collect_thread_events(&mut self, event: &protocol::Event) -> Vec { - match &event.msg { - protocol::EventMsg::SessionConfigured(ev) => self.handle_session_configured(ev), - protocol::EventMsg::ThreadNameUpdated(_) => Vec::new(), - protocol::EventMsg::AgentMessage(ev) => self.handle_agent_message(ev), - protocol::EventMsg::ItemCompleted(protocol::ItemCompletedEvent { - item: codex_protocol::items::TurnItem::Plan(item), - .. - }) => { - self.last_proposed_plan = Some(item.text.clone()); - Vec::new() - } - protocol::EventMsg::AgentReasoning(ev) => self.handle_reasoning_event(ev), - protocol::EventMsg::ExecCommandBegin(ev) => self.handle_exec_command_begin(ev), - protocol::EventMsg::ExecCommandEnd(ev) => self.handle_exec_command_end(ev), - protocol::EventMsg::TerminalInteraction(ev) => self.handle_terminal_interaction(ev), - protocol::EventMsg::ExecCommandOutputDelta(ev) => { - self.handle_output_chunk(&ev.call_id, &ev.chunk) - } - protocol::EventMsg::McpToolCallBegin(ev) => self.handle_mcp_tool_call_begin(ev), - protocol::EventMsg::McpToolCallEnd(ev) => self.handle_mcp_tool_call_end(ev), - protocol::EventMsg::CollabAgentSpawnBegin(ev) => self.handle_collab_spawn_begin(ev), - protocol::EventMsg::CollabAgentSpawnEnd(ev) => self.handle_collab_spawn_end(ev), - protocol::EventMsg::CollabAgentInteractionBegin(ev) => { - self.handle_collab_interaction_begin(ev) - } - protocol::EventMsg::CollabAgentInteractionEnd(ev) => { - self.handle_collab_interaction_end(ev) - } - protocol::EventMsg::CollabWaitingBegin(ev) => self.handle_collab_wait_begin(ev), - protocol::EventMsg::CollabWaitingEnd(ev) => self.handle_collab_wait_end(ev), - protocol::EventMsg::CollabCloseBegin(ev) => self.handle_collab_close_begin(ev), - protocol::EventMsg::CollabCloseEnd(ev) => self.handle_collab_close_end(ev), - protocol::EventMsg::PatchApplyBegin(ev) => self.handle_patch_apply_begin(ev), - protocol::EventMsg::PatchApplyEnd(ev) => self.handle_patch_apply_end(ev), - protocol::EventMsg::WebSearchBegin(ev) => self.handle_web_search_begin(ev), - protocol::EventMsg::WebSearchEnd(ev) => self.handle_web_search_end(ev), - protocol::EventMsg::TokenCount(ev) => { - if let Some(info) = &ev.info { - self.last_total_token_usage = Some(info.total_token_usage.clone()); - } - Vec::new() - } - protocol::EventMsg::TurnStarted(ev) => self.handle_task_started(ev), - protocol::EventMsg::TurnComplete(_) => self.handle_task_complete(), - protocol::EventMsg::Error(ev) => { - let error = ThreadErrorEvent { - message: ev.message.clone(), - }; - self.last_critical_error = Some(error.clone()); - vec![ThreadEvent::Error(error)] - } - protocol::EventMsg::Warning(ev) => { - let item = ThreadItem { - id: self.get_next_item_id(), - details: ThreadItemDetails::Error(ErrorItem { - message: ev.message.clone(), - }), - }; - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] - } - protocol::EventMsg::StreamError(ev) => { - let message = match &ev.additional_details { - Some(details) if !details.trim().is_empty() => { - format!("{} ({})", ev.message, details) - } - _ => ev.message.clone(), - }; - vec![ThreadEvent::Error(ThreadErrorEvent { message })] - } - protocol::EventMsg::PlanUpdate(ev) => self.handle_plan_update(ev), - _ => Vec::new(), - } + pub fn final_message(&self) -> Option<&str> { + self.final_message.as_deref() } - fn get_next_item_id(&self) -> String { - format!( - "item_{}", - self.next_event_id - .fetch_add(1, std::sync::atomic::Ordering::SeqCst) - ) + fn next_item_id(&self) -> String { + format!("item_{}", self.next_item_id.fetch_add(1, Ordering::SeqCst)) } - fn handle_session_configured( - &self, - payload: &protocol::SessionConfiguredEvent, - ) -> Vec { - vec![ThreadEvent::ThreadStarted(ThreadStartedEvent { - thread_id: payload.session_id.to_string(), - })] - } - - fn handle_web_search_begin(&mut self, ev: &protocol::WebSearchBeginEvent) -> Vec { - if self.running_web_search_calls.contains_key(&ev.call_id) { - return Vec::new(); - } - let item_id = self.get_next_item_id(); - self.running_web_search_calls - .insert(ev.call_id.clone(), item_id.clone()); - let item = ThreadItem { - id: item_id, - details: ThreadItemDetails::WebSearch(WebSearchItem { - id: ev.call_id.clone(), - query: String::new(), - action: WebSearchAction::Other, - }), - }; - - vec![ThreadEvent::ItemStarted(ItemStartedEvent { item })] - } - - fn handle_web_search_end(&mut self, ev: &protocol::WebSearchEndEvent) -> Vec { - let item_id = self - .running_web_search_calls - .remove(&ev.call_id) - .unwrap_or_else(|| self.get_next_item_id()); - let item = ThreadItem { - id: item_id, - details: ThreadItemDetails::WebSearch(WebSearchItem { - id: ev.call_id.clone(), - query: ev.query.clone(), - action: ev.action.clone(), - }), - }; - - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] - } - - fn handle_output_chunk(&mut self, _call_id: &str, _chunk: &[u8]) -> Vec { - //TODO see how we want to process them - vec![] - } - - fn handle_terminal_interaction( - &mut self, - _ev: &protocol::TerminalInteractionEvent, - ) -> Vec { - //TODO see how we want to process them - vec![] - } - - fn handle_agent_message(&self, payload: &protocol::AgentMessageEvent) -> Vec { - let item = ThreadItem { - id: self.get_next_item_id(), - - details: ThreadItemDetails::AgentMessage(AgentMessageItem { - text: payload.message.clone(), - }), - }; - - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] - } - - fn handle_reasoning_event(&self, ev: &protocol::AgentReasoningEvent) -> Vec { - let item = ThreadItem { - id: self.get_next_item_id(), - - details: ThreadItemDetails::Reasoning(ReasoningItem { - text: ev.text.clone(), - }), - }; - - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] - } - fn handle_exec_command_begin( - &mut self, - ev: &protocol::ExecCommandBeginEvent, - ) -> Vec { - let item_id = self.get_next_item_id(); - - let command_string = match shlex::try_join(ev.command.iter().map(String::as_str)) { - Ok(command_string) => command_string, - Err(e) => { - warn!( - call_id = ev.call_id, - "Failed to stringify command: {e:?}; skipping item.started" - ); - ev.command.join(" ") - } - }; - - self.running_commands.insert( - ev.call_id.clone(), - RunningCommand { - command: command_string.clone(), - item_id: item_id.clone(), - aggregated_output: String::new(), - }, + #[allow(clippy::print_stdout)] + fn emit(&self, event: ThreadEvent) { + println!( + "{}", + serde_json::to_string(&event).unwrap_or_else(|err| { + json!({ + "type": "error", + "message": format!("failed to serialize exec json event: {err}"), + }) + .to_string() + }) ); - - let item = ThreadItem { - id: item_id, - details: ThreadItemDetails::CommandExecution(CommandExecutionItem { - command: command_string, - aggregated_output: String::new(), - exit_code: None, - status: CommandExecutionStatus::InProgress, - }), - }; - - vec![ThreadEvent::ItemStarted(ItemStartedEvent { item })] } - fn handle_mcp_tool_call_begin( - &mut self, - ev: &protocol::McpToolCallBeginEvent, - ) -> Vec { - let item_id = self.get_next_item_id(); - let server = ev.invocation.server.clone(); - let tool = ev.invocation.tool.clone(); - let arguments = ev.invocation.arguments.clone().unwrap_or(JsonValue::Null); - - self.running_mcp_tool_calls.insert( - ev.call_id.clone(), - RunningMcpToolCall { - server: server.clone(), - tool: tool.clone(), - item_id: item_id.clone(), - arguments: arguments.clone(), - }, - ); - - let item = ThreadItem { - id: item_id, - details: ThreadItemDetails::McpToolCall(McpToolCallItem { - server, - tool, - arguments, - result: None, - error: None, - status: McpToolCallStatus::InProgress, - }), + fn usage_from_last_total(&self) -> Usage { + let Some(usage) = self.last_total_token_usage.as_ref() else { + return Usage::default(); }; - - vec![ThreadEvent::ItemStarted(ItemStartedEvent { item })] + Usage { + input_tokens: usage.total.input_tokens, + cached_input_tokens: usage.total.cached_input_tokens, + output_tokens: usage.total.output_tokens, + } } - fn handle_mcp_tool_call_end(&mut self, ev: &protocol::McpToolCallEndEvent) -> Vec { - let status = if ev.is_success() { - McpToolCallStatus::Completed - } else { - McpToolCallStatus::Failed - }; - - let (server, tool, item_id, arguments) = - match self.running_mcp_tool_calls.remove(&ev.call_id) { - Some(running) => ( - running.server, - running.tool, - running.item_id, - running.arguments, + pub fn map_todo_items(plan: &[codex_app_server_protocol::TurnPlanStep]) -> Vec { + plan.iter() + .map(|step| TodoItem { + text: step.step.clone(), + completed: matches!( + step.status, + codex_app_server_protocol::TurnPlanStepStatus::Completed ), - None => { - warn!( - call_id = ev.call_id, - "Received McpToolCallEnd without begin; synthesizing new item" - ); - ( - ev.invocation.server.clone(), - ev.invocation.tool.clone(), - self.get_next_item_id(), - ev.invocation.arguments.clone().unwrap_or(JsonValue::Null), - ) + }) + .collect() + } + + fn map_item_with_id( + item: ThreadItem, + make_id: impl FnOnce() -> String, + ) -> Option { + match item { + ThreadItem::AgentMessage { text, .. } => Some(ExecThreadItem { + id: make_id(), + details: ThreadItemDetails::AgentMessage(AgentMessageItem { text }), + }), + ThreadItem::Reasoning { summary, .. } => { + let text = summary.join("\n"); + if text.trim().is_empty() { + return None; } - }; - - let (result, error) = match &ev.result { - Ok(value) => { - let result = McpToolCallItemResult { - content: value.content.clone(), - structured_content: value.structured_content.clone(), - }; - (Some(result), None) + Some(ExecThreadItem { + id: make_id(), + details: ThreadItemDetails::Reasoning(ReasoningItem { text }), + }) } - Err(message) => ( - None, - Some(McpToolCallItemError { - message: message.clone(), + ThreadItem::CommandExecution { + command, + aggregated_output, + exit_code, + status, + .. + } => Some(ExecThreadItem { + id: make_id(), + details: ThreadItemDetails::CommandExecution(CommandExecutionItem { + command, + aggregated_output: aggregated_output.unwrap_or_default(), + exit_code, + status: match status { + CommandExecutionStatus::InProgress => ExecCommandExecutionStatus::InProgress, + CommandExecutionStatus::Completed => ExecCommandExecutionStatus::Completed, + CommandExecutionStatus::Failed => ExecCommandExecutionStatus::Failed, + CommandExecutionStatus::Declined => ExecCommandExecutionStatus::Declined, + }, }), - ), - }; - - let item = ThreadItem { - id: item_id, - details: ThreadItemDetails::McpToolCall(McpToolCallItem { + }), + ThreadItem::FileChange { + changes, status, .. + } => Some(ExecThreadItem { + id: make_id(), + details: ThreadItemDetails::FileChange(FileChangeItem { + changes: changes + .into_iter() + .map(|change| FileUpdateChange { + path: change.path, + kind: match change.kind { + PatchChangeKind::Add => ExecPatchChangeKind::Add, + PatchChangeKind::Delete => ExecPatchChangeKind::Delete, + PatchChangeKind::Update { .. } => ExecPatchChangeKind::Update, + }, + }) + .collect(), + status: match status { + PatchApplyStatus::InProgress => ExecPatchApplyStatus::InProgress, + PatchApplyStatus::Completed => ExecPatchApplyStatus::Completed, + PatchApplyStatus::Failed | PatchApplyStatus::Declined => { + ExecPatchApplyStatus::Failed + } + }, + }), + }), + ThreadItem::McpToolCall { server, tool, + status, arguments, result, error, - status, + .. + } => Some(ExecThreadItem { + id: make_id(), + details: ThreadItemDetails::McpToolCall(McpToolCallItem { + server, + tool, + status: match status { + McpToolCallStatus::InProgress => ExecMcpToolCallStatus::InProgress, + McpToolCallStatus::Completed => ExecMcpToolCallStatus::Completed, + McpToolCallStatus::Failed => ExecMcpToolCallStatus::Failed, + }, + arguments, + result: result.map(|result| McpToolCallItemResult { + content: result.content, + structured_content: result.structured_content, + }), + error: error.map(|error| McpToolCallItemError { + message: error.message, + }), + }), }), - }; - - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] - } - - fn handle_collab_spawn_begin(&mut self, ev: &CollabAgentSpawnBeginEvent) -> Vec { - self.start_collab_tool_call( - &ev.call_id, - CollabTool::SpawnAgent, - ev.sender_thread_id.to_string(), - Vec::new(), - Some(ev.prompt.clone()), - ) - } - - fn handle_collab_spawn_end(&mut self, ev: &CollabAgentSpawnEndEvent) -> Vec { - let (receiver_thread_ids, agents_states) = match ev.new_thread_id { - Some(id) => { - let receiver_id = id.to_string(); - let agent_state = CollabAgentState::from(ev.status.clone()); - ( - vec![receiver_id.clone()], - [(receiver_id, agent_state)].into_iter().collect(), - ) - } - None => (Vec::new(), HashMap::new()), - }; - let status = if ev.new_thread_id.is_some() && !is_collab_failure(&ev.status) { - CollabToolCallStatus::Completed - } else { - CollabToolCallStatus::Failed - }; - self.finish_collab_tool_call( - &ev.call_id, - CollabTool::SpawnAgent, - ev.sender_thread_id.to_string(), - receiver_thread_ids, - Some(ev.prompt.clone()), - agents_states, - status, - ) - } - - fn handle_collab_interaction_begin( - &mut self, - ev: &CollabAgentInteractionBeginEvent, - ) -> Vec { - self.start_collab_tool_call( - &ev.call_id, - CollabTool::SendInput, - ev.sender_thread_id.to_string(), - vec![ev.receiver_thread_id.to_string()], - Some(ev.prompt.clone()), - ) - } - - fn handle_collab_interaction_end( - &mut self, - ev: &CollabAgentInteractionEndEvent, - ) -> Vec { - let receiver_id = ev.receiver_thread_id.to_string(); - let agent_state = CollabAgentState::from(ev.status.clone()); - let status = if is_collab_failure(&ev.status) { - CollabToolCallStatus::Failed - } else { - CollabToolCallStatus::Completed - }; - self.finish_collab_tool_call( - &ev.call_id, - CollabTool::SendInput, - ev.sender_thread_id.to_string(), - vec![receiver_id.clone()], - Some(ev.prompt.clone()), - [(receiver_id, agent_state)].into_iter().collect(), - status, - ) - } - - fn handle_collab_wait_begin(&mut self, ev: &CollabWaitingBeginEvent) -> Vec { - self.start_collab_tool_call( - &ev.call_id, - CollabTool::Wait, - ev.sender_thread_id.to_string(), - ev.receiver_thread_ids - .iter() - .map(ToString::to_string) - .collect(), - /*prompt*/ None, - ) - } - - fn handle_collab_wait_end(&mut self, ev: &CollabWaitingEndEvent) -> Vec { - let status = if ev.statuses.values().any(is_collab_failure) { - CollabToolCallStatus::Failed - } else { - CollabToolCallStatus::Completed - }; - let mut receiver_thread_ids = ev - .statuses - .keys() - .map(ToString::to_string) - .collect::>(); - receiver_thread_ids.sort(); - let agents_states = ev - .statuses - .iter() - .map(|(thread_id, status)| { - ( - thread_id.to_string(), - CollabAgentState::from(status.clone()), - ) - }) - .collect(); - self.finish_collab_tool_call( - &ev.call_id, - CollabTool::Wait, - ev.sender_thread_id.to_string(), - receiver_thread_ids, - /*prompt*/ None, - agents_states, - status, - ) - } - - fn handle_collab_close_begin(&mut self, ev: &CollabCloseBeginEvent) -> Vec { - self.start_collab_tool_call( - &ev.call_id, - CollabTool::CloseAgent, - ev.sender_thread_id.to_string(), - vec![ev.receiver_thread_id.to_string()], - /*prompt*/ None, - ) - } - - fn handle_collab_close_end(&mut self, ev: &CollabCloseEndEvent) -> Vec { - let receiver_id = ev.receiver_thread_id.to_string(); - let agent_state = CollabAgentState::from(ev.status.clone()); - let status = if is_collab_failure(&ev.status) { - CollabToolCallStatus::Failed - } else { - CollabToolCallStatus::Completed - }; - self.finish_collab_tool_call( - &ev.call_id, - CollabTool::CloseAgent, - ev.sender_thread_id.to_string(), - vec![receiver_id.clone()], - /*prompt*/ None, - [(receiver_id, agent_state)].into_iter().collect(), - status, - ) - } - - fn start_collab_tool_call( - &mut self, - call_id: &str, - tool: CollabTool, - sender_thread_id: String, - receiver_thread_ids: Vec, - prompt: Option, - ) -> Vec { - let item_id = self.get_next_item_id(); - self.running_collab_tool_calls.insert( - call_id.to_string(), - RunningCollabToolCall { - tool: tool.clone(), - item_id: item_id.clone(), - }, - ); - let item = ThreadItem { - id: item_id, - details: ThreadItemDetails::CollabToolCall(CollabToolCallItem { - tool, - sender_thread_id, - receiver_thread_ids, - prompt, - agents_states: HashMap::new(), - status: CollabToolCallStatus::InProgress, - }), - }; - vec![ThreadEvent::ItemStarted(ItemStartedEvent { item })] - } - - #[allow(clippy::too_many_arguments)] - fn finish_collab_tool_call( - &mut self, - call_id: &str, - tool: CollabTool, - sender_thread_id: String, - receiver_thread_ids: Vec, - prompt: Option, - agents_states: HashMap, - status: CollabToolCallStatus, - ) -> Vec { - let (tool, item_id) = match self.running_collab_tool_calls.remove(call_id) { - Some(running) => (running.tool, running.item_id), - None => { - warn!( - call_id, - "Received collab tool end without begin; synthesizing new item" - ); - (tool, self.get_next_item_id()) - } - }; - let item = ThreadItem { - id: item_id, - details: ThreadItemDetails::CollabToolCall(CollabToolCallItem { + ThreadItem::CollabAgentToolCall { tool, sender_thread_id, receiver_thread_ids, prompt, agents_states, status, - }), - }; - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] - } - - fn handle_patch_apply_begin( - &mut self, - ev: &protocol::PatchApplyBeginEvent, - ) -> Vec { - self.running_patch_applies - .insert(ev.call_id.clone(), ev.clone()); - - Vec::new() - } - - fn map_change_kind(&self, kind: &protocol::FileChange) -> PatchChangeKind { - match kind { - protocol::FileChange::Add { .. } => PatchChangeKind::Add, - protocol::FileChange::Delete { .. } => PatchChangeKind::Delete, - protocol::FileChange::Update { .. } => PatchChangeKind::Update, - } - } - - fn handle_patch_apply_end(&mut self, ev: &protocol::PatchApplyEndEvent) -> Vec { - if let Some(running_patch_apply) = self.running_patch_applies.remove(&ev.call_id) { - let status = if ev.success { - PatchApplyStatus::Completed - } else { - PatchApplyStatus::Failed - }; - let item = ThreadItem { - id: self.get_next_item_id(), - - details: ThreadItemDetails::FileChange(FileChangeItem { - changes: running_patch_apply - .changes - .iter() - .map(|(path, change)| FileUpdateChange { - path: path.to_str().unwrap_or("").to_string(), - kind: self.map_change_kind(change), + .. + } => Some(ExecThreadItem { + id: make_id(), + details: ThreadItemDetails::CollabToolCall(CollabToolCallItem { + tool: match tool { + CollabAgentTool::SpawnAgent => CollabTool::SpawnAgent, + CollabAgentTool::SendInput => CollabTool::SendInput, + CollabAgentTool::ResumeAgent => CollabTool::Wait, + CollabAgentTool::Wait => CollabTool::Wait, + CollabAgentTool::CloseAgent => CollabTool::CloseAgent, + }, + sender_thread_id, + receiver_thread_ids, + prompt, + agents_states: agents_states + .into_iter() + .map(|(thread_id, state)| { + ( + thread_id, + CollabAgentState { + status: match state.status { + codex_app_server_protocol::CollabAgentStatus::PendingInit => { + CollabAgentStatus::PendingInit + } + codex_app_server_protocol::CollabAgentStatus::Running => { + CollabAgentStatus::Running + } + codex_app_server_protocol::CollabAgentStatus::Interrupted => { + CollabAgentStatus::Interrupted + } + codex_app_server_protocol::CollabAgentStatus::Completed => { + CollabAgentStatus::Completed + } + codex_app_server_protocol::CollabAgentStatus::Errored => { + CollabAgentStatus::Errored + } + codex_app_server_protocol::CollabAgentStatus::Shutdown => { + CollabAgentStatus::Shutdown + } + codex_app_server_protocol::CollabAgentStatus::NotFound => { + CollabAgentStatus::NotFound + } + }, + message: state.message, + }, + ) }) .collect(), - status, + status: match status { + CollabAgentToolCallStatus::InProgress => CollabToolCallStatus::InProgress, + CollabAgentToolCallStatus::Completed => CollabToolCallStatus::Completed, + CollabAgentToolCallStatus::Failed => CollabToolCallStatus::Failed, + }, }), - }; - - return vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })]; - } - - Vec::new() - } - - fn handle_exec_command_end(&mut self, ev: &protocol::ExecCommandEndEvent) -> Vec { - let Some(RunningCommand { - command, - item_id, - aggregated_output, - }) = self.running_commands.remove(&ev.call_id) - else { - warn!( - call_id = ev.call_id, - "ExecCommandEnd without matching ExecCommandBegin; skipping item.completed" - ); - return Vec::new(); - }; - let status = if ev.exit_code == 0 { - CommandExecutionStatus::Completed - } else { - CommandExecutionStatus::Failed - }; - let aggregated_output = if ev.aggregated_output.is_empty() { - aggregated_output - } else { - ev.aggregated_output.clone() - }; - let item = ThreadItem { - id: item_id, - - details: ThreadItemDetails::CommandExecution(CommandExecutionItem { - command, - aggregated_output, - exit_code: Some(ev.exit_code), - status, }), - }; - - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] + ThreadItem::WebSearch { + id: raw_id, + query, + action, + } => Some(ExecThreadItem { + id: make_id(), + details: ThreadItemDetails::WebSearch(WebSearchItem { + id: raw_id, + query, + action: match action { + Some(action) => serde_json::from_value( + serde_json::to_value(action).unwrap_or_else(|_| json!("other")), + ) + .unwrap_or(WebSearchAction::Other), + None => WebSearchAction::Other, + }, + }), + }), + _ => None, + } } - fn todo_items_from_plan(&self, args: &UpdatePlanArgs) -> Vec { - args.plan + fn started_item_id(&mut self, raw_id: &str) -> String { + if let Some(existing) = self.raw_to_exec_item_id.get(raw_id) { + return existing.clone(); + } + let exec_id = self.next_item_id(); + self.raw_to_exec_item_id + .insert(raw_id.to_string(), exec_id.clone()); + exec_id + } + + fn completed_item_id(&mut self, raw_id: &str) -> String { + self.raw_to_exec_item_id + .remove(raw_id) + .unwrap_or_else(|| self.next_item_id()) + } + + fn map_started_item(&mut self, item: ThreadItem) -> Option { + match item { + ThreadItem::AgentMessage { .. } | ThreadItem::Reasoning { .. } => None, + other => { + let raw_id = other.id().to_string(); + Self::map_item_with_id(other, || self.started_item_id(&raw_id)) + } + } + } + + fn map_completed_item_mut(&mut self, item: ThreadItem) -> Option { + if let ThreadItem::Reasoning { summary, .. } = &item + && summary.join("\n").trim().is_empty() + { + return None; + } + match &item { + ThreadItem::AgentMessage { .. } | ThreadItem::Reasoning { .. } => { + Self::map_item_with_id(item, || self.next_item_id()) + } + other => { + let raw_id = other.id().to_string(); + Self::map_item_with_id(item, || self.completed_item_id(&raw_id)) + } + } + } + + fn reconcile_unfinished_started_items( + &mut self, + turn_items: &[ThreadItem], + ) -> Vec { + turn_items .iter() - .map(|p| TodoItem { - text: p.step.clone(), - completed: matches!(p.status, StepStatus::Completed), + .filter_map(|item| { + let raw_id = item.id().to_string(); + if !self.raw_to_exec_item_id.contains_key(&raw_id) { + return None; + } + self.map_completed_item_mut(item.clone()) + .map(|item| ThreadEvent::ItemCompleted(ItemCompletedEvent { item })) }) .collect() } - fn handle_plan_update(&mut self, args: &UpdatePlanArgs) -> Vec { - let items = self.todo_items_from_plan(args); - - if let Some(running) = &mut self.running_todo_list { - running.items = items.clone(); - let item = ThreadItem { - id: running.item_id.clone(), - details: ThreadItemDetails::TodoList(TodoListItem { items }), - }; - return vec![ThreadEvent::ItemUpdated(ItemUpdatedEvent { item })]; - } - - let item_id = self.get_next_item_id(); - self.running_todo_list = Some(RunningTodoList { - item_id: item_id.clone(), - items: items.clone(), - }); - let item = ThreadItem { - id: item_id, - details: ThreadItemDetails::TodoList(TodoListItem { items }), - }; - vec![ThreadEvent::ItemStarted(ItemStartedEvent { item })] - } - - fn handle_task_started(&mut self, _: &protocol::TurnStartedEvent) -> Vec { - self.last_critical_error = None; - vec![ThreadEvent::TurnStarted(TurnStartedEvent {})] - } - - fn handle_task_complete(&mut self) -> Vec { - let usage = if let Some(u) = &self.last_total_token_usage { - Usage { - input_tokens: u.input_tokens, - cached_input_tokens: u.cached_input_tokens, - output_tokens: u.output_tokens, - } - } else { - Usage::default() - }; - - let mut items = Vec::new(); - - if let Some(running) = self.running_todo_list.take() { - let item = ThreadItem { - id: running.item_id, - details: ThreadItemDetails::TodoList(TodoListItem { - items: running.items, - }), - }; - items.push(ThreadEvent::ItemCompleted(ItemCompletedEvent { item })); - } - - if !self.running_commands.is_empty() { - for (_, running) in self.running_commands.drain() { - let item = ThreadItem { - id: running.item_id, - details: ThreadItemDetails::CommandExecution(CommandExecutionItem { - command: running.command, - aggregated_output: running.aggregated_output, - exit_code: None, - status: CommandExecutionStatus::Completed, - }), - }; - items.push(ThreadEvent::ItemCompleted(ItemCompletedEvent { item })); - } - } - - if let Some(error) = self.last_critical_error.take() { - items.push(ThreadEvent::TurnFailed(TurnFailedEvent { error })); - } else { - items.push(ThreadEvent::TurnCompleted(TurnCompletedEvent { usage })); - } - + fn final_message_from_turn_items(items: &[ThreadItem]) -> Option { items + .iter() + .rev() + .find_map(|item| match item { + ThreadItem::AgentMessage { text, .. } => Some(text.clone()), + _ => None, + }) + .or_else(|| { + items.iter().rev().find_map(|item| match item { + ThreadItem::Plan { text, .. } => Some(text.clone()), + _ => None, + }) + }) } -} -fn is_collab_failure(status: &CoreAgentStatus) -> bool { - matches!( - status, - CoreAgentStatus::Errored(_) | CoreAgentStatus::NotFound - ) -} + pub fn thread_started_event(session_configured: &SessionConfiguredEvent) -> ThreadEvent { + ThreadEvent::ThreadStarted(ThreadStartedEvent { + thread_id: session_configured.session_id.to_string(), + }) + } -impl From for CollabAgentState { - fn from(value: CoreAgentStatus) -> Self { - match value { - CoreAgentStatus::PendingInit => Self { - status: CollabAgentStatus::PendingInit, - message: None, - }, - CoreAgentStatus::Running => Self { - status: CollabAgentStatus::Running, - message: None, - }, - CoreAgentStatus::Interrupted => Self { - status: CollabAgentStatus::Interrupted, - message: None, - }, - CoreAgentStatus::Completed(message) => Self { - status: CollabAgentStatus::Completed, - message, - }, - CoreAgentStatus::Errored(message) => Self { - status: CollabAgentStatus::Errored, - message: Some(message), - }, - CoreAgentStatus::Shutdown => Self { - status: CollabAgentStatus::Shutdown, - message: None, - }, - CoreAgentStatus::NotFound => Self { - status: CollabAgentStatus::NotFound, - message: None, - }, + pub fn collect_warning(&mut self, message: String) -> CollectedThreadEvents { + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: self.next_item_id(), + details: ThreadItemDetails::Error(ErrorItem { message }), + }, + })], + status: CodexStatus::Running, } } + + pub fn collect_thread_events( + &mut self, + notification: ServerNotification, + ) -> CollectedThreadEvents { + let mut events = Vec::new(); + let status = match notification { + ServerNotification::ConfigWarning(notification) => { + let message = match notification.details { + Some(details) if !details.is_empty() => { + format!("{} ({details})", notification.summary) + } + _ => notification.summary, + }; + events.push(ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: self.next_item_id(), + details: ThreadItemDetails::Error(ErrorItem { message }), + }, + })); + CodexStatus::Running + } + ServerNotification::Error(notification) => { + let message = match notification.error.additional_details { + Some(details) if !details.is_empty() => { + format!("{} ({details})", notification.error.message) + } + _ => notification.error.message, + }; + let error = ThreadErrorEvent { message }; + self.last_critical_error = Some(error.clone()); + events.push(ThreadEvent::Error(error)); + CodexStatus::Running + } + ServerNotification::DeprecationNotice(notification) => { + let message = match notification.details { + Some(details) if !details.is_empty() => { + format!("{} ({details})", notification.summary) + } + _ => notification.summary, + }; + events.push(ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: self.next_item_id(), + details: ThreadItemDetails::Error(ErrorItem { message }), + }, + })); + CodexStatus::Running + } + ServerNotification::HookStarted(_) | ServerNotification::HookCompleted(_) => { + CodexStatus::Running + } + ServerNotification::ItemStarted(notification) => { + if let Some(item) = self.map_started_item(notification.item) { + events.push(ThreadEvent::ItemStarted(ItemStartedEvent { item })); + } + CodexStatus::Running + } + ServerNotification::ItemCompleted(notification) => { + if let Some(item) = self.map_completed_item_mut(notification.item) { + if let ThreadItemDetails::AgentMessage(AgentMessageItem { text }) = + &item.details + { + self.final_message = Some(text.clone()); + } + events.push(ThreadEvent::ItemCompleted(ItemCompletedEvent { item })); + } + CodexStatus::Running + } + ServerNotification::ModelRerouted(notification) => { + events.push(ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: self.next_item_id(), + details: ThreadItemDetails::Error(ErrorItem { + message: format!( + "model rerouted: {} -> {} ({:?})", + notification.from_model, notification.to_model, notification.reason + ), + }), + }, + })); + CodexStatus::Running + } + ServerNotification::ThreadTokenUsageUpdated(notification) => { + self.last_total_token_usage = Some(notification.token_usage); + CodexStatus::Running + } + ServerNotification::TurnCompleted(notification) => { + if let Some(running) = self.running_todo_list.take() { + events.push(ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: running.item_id, + details: ThreadItemDetails::TodoList(TodoListItem { + items: running.items, + }), + }, + })); + } + events.extend(self.reconcile_unfinished_started_items(¬ification.turn.items)); + match notification.turn.status { + TurnStatus::Completed => { + if let Some(final_message) = + Self::final_message_from_turn_items(notification.turn.items.as_slice()) + { + self.final_message = Some(final_message); + } + self.emit_final_message_on_shutdown = true; + events.push(ThreadEvent::TurnCompleted(TurnCompletedEvent { + usage: self.usage_from_last_total(), + })); + CodexStatus::InitiateShutdown + } + TurnStatus::Failed => { + self.final_message = None; + self.emit_final_message_on_shutdown = false; + let error = notification + .turn + .error + .map(|error| ThreadErrorEvent { + message: match error.additional_details { + Some(details) if !details.is_empty() => { + format!("{} ({details})", error.message) + } + _ => error.message, + }, + }) + .or_else(|| self.last_critical_error.clone()) + .unwrap_or_else(|| ThreadErrorEvent { + message: "turn failed".to_string(), + }); + events.push(ThreadEvent::TurnFailed(TurnFailedEvent { error })); + CodexStatus::InitiateShutdown + } + TurnStatus::Interrupted => { + self.final_message = None; + self.emit_final_message_on_shutdown = false; + CodexStatus::InitiateShutdown + } + TurnStatus::InProgress => CodexStatus::Running, + } + } + ServerNotification::TurnDiffUpdated(_) => CodexStatus::Running, + ServerNotification::TurnPlanUpdated(notification) => { + let items = Self::map_todo_items(¬ification.plan); + if let Some(running) = self.running_todo_list.as_mut() { + running.items = items.clone(); + let item_id = running.item_id.clone(); + events.push(ThreadEvent::ItemUpdated(ItemUpdatedEvent { + item: ExecThreadItem { + id: item_id, + details: ThreadItemDetails::TodoList(TodoListItem { items }), + }, + })); + } else { + let item_id = self.next_item_id(); + self.running_todo_list = Some(RunningTodoList { + item_id: item_id.clone(), + items: items.clone(), + }); + events.push(ThreadEvent::ItemStarted(ItemStartedEvent { + item: ExecThreadItem { + id: item_id, + details: ThreadItemDetails::TodoList(TodoListItem { items }), + }, + })); + } + CodexStatus::Running + } + ServerNotification::TurnStarted(_) => { + events.push(ThreadEvent::TurnStarted(TurnStartedEvent {})); + CodexStatus::Running + } + _ => CodexStatus::Running, + }; + + CollectedThreadEvents { events, status } + } } impl EventProcessor for EventProcessorWithJsonOutput { - fn print_config_summary(&mut self, _: &Config, _: &str, ev: &protocol::SessionConfiguredEvent) { - self.process_event(protocol::Event { - id: "".to_string(), - msg: protocol::EventMsg::SessionConfigured(ev.clone()), - }); + fn print_config_summary( + &mut self, + _: &Config, + _: &str, + session_configured: &SessionConfiguredEvent, + ) { + self.emit(Self::thread_started_event(session_configured)); } - #[allow(clippy::print_stdout)] - fn process_event(&mut self, event: protocol::Event) -> CodexStatus { - let aggregated = self.collect_thread_events(&event); - for conv_event in aggregated { - match serde_json::to_string(&conv_event) { - Ok(line) => { - println!("{line}"); - } - Err(e) => { - error!("Failed to serialize event: {e:?}"); - } - } + fn process_server_notification(&mut self, notification: ServerNotification) -> CodexStatus { + let collected = self.collect_thread_events(notification); + for event in collected.events { + self.emit(event); } + collected.status + } - let protocol::Event { msg, .. } = event; + fn process_warning(&mut self, message: String) -> CodexStatus { + let collected = self.collect_warning(message); + for event in collected.events { + self.emit(event); + } + collected.status + } - match msg { - protocol::EventMsg::TurnComplete(protocol::TurnCompleteEvent { - last_agent_message, - .. - }) => { - if let Some(output_file) = self.last_message_path.as_deref() { - let last_message = last_agent_message - .as_deref() - .or(self.last_proposed_plan.as_deref()); - handle_last_message(last_message, output_file); - } - CodexStatus::InitiateShutdown - } - protocol::EventMsg::TurnAborted(_) => CodexStatus::InitiateShutdown, - protocol::EventMsg::ShutdownComplete => CodexStatus::Shutdown, - _ => CodexStatus::Running, + fn print_final_output(&mut self) { + if self.emit_final_message_on_shutdown + && let Some(path) = self.last_message_path.as_deref() + { + handle_last_message(self.final_message.as_deref(), path); } } } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + #[test] + fn failed_turn_does_not_overwrite_output_last_message_file() { + let tempdir = tempdir().expect("create tempdir"); + let output_path = tempdir.path().join("last-message.txt"); + std::fs::write(&output_path, "keep existing contents").expect("seed output file"); + + let mut processor = EventProcessorWithJsonOutput::new(Some(output_path.clone())); + + let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( + codex_app_server_protocol::ItemCompletedNotification { + item: ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "partial answer".to_string(), + phase: None, + memory_citation: None, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!(collected.status, CodexStatus::Running); + assert_eq!(processor.final_message(), Some("partial answer")); + + let status = processor.process_server_notification(ServerNotification::TurnCompleted( + codex_app_server_protocol::TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: codex_app_server_protocol::Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::Failed, + error: Some(codex_app_server_protocol::TurnError { + message: "turn failed".to_string(), + additional_details: None, + codex_error_info: None, + }), + }, + }, + )); + + assert_eq!(status, CodexStatus::InitiateShutdown); + assert_eq!(processor.final_message(), None); + + EventProcessor::print_final_output(&mut processor); + + assert_eq!( + std::fs::read_to_string(&output_path).expect("read output file"), + "keep existing contents" + ); + } +} diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index c10b86f69..29ae7bf25 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -17,11 +17,9 @@ 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::ChatgptAuthTokensRefreshResponse; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::JSONRPCErrorError; -use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::McpServerElicitationAction; use codex_app_server_protocol::McpServerElicitationRequestResponse; use codex_app_server_protocol::RequestId; @@ -30,8 +28,16 @@ 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::Thread as AppServerThread; +use codex_app_server_protocol::ThreadItem as AppServerThreadItem; +use codex_app_server_protocol::ThreadListParams; +use codex_app_server_protocol::ThreadListResponse; +use codex_app_server_protocol::ThreadReadParams; +use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadSortKey; +use codex_app_server_protocol::ThreadSourceKind; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadUnsubscribeParams; @@ -40,9 +46,9 @@ 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::TurnStartedNotification; use codex_arg0::Arg0DispatchPaths; -use codex_cloud_requirements::cloud_requirements_loader; -use codex_core::AuthManager; +use codex_cloud_requirements::cloud_requirements_loader_for_storage; use codex_core::LMSTUDIO_OSS_PROVIDER_ID; use codex_core::OLLAMA_OSS_PROVIDER_ID; use codex_core::auth::AuthConfig; @@ -59,16 +65,17 @@ use codex_core::config_loader::LoaderOverrides; use codex_core::config_loader::format_config_error_with_source; use codex_core::format_exec_policy_error_with_source; use codex_core::git_info::get_git_repo_root; +use codex_core::path_utils; use codex_feedback::CodexFeedback; use codex_otel::set_parent_from_context; use codex_otel::traceparent_context_from_env; -use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::config_types::SandboxMode; use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::Event; -use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::RolloutLine; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionSource; use codex_protocol::user_input::UserInput; @@ -79,10 +86,9 @@ use event_processor_with_human_output::EventProcessorWithHumanOutput; use event_processor_with_jsonl_output::EventProcessorWithJsonOutput; use serde_json::Value; use std::collections::HashMap; -use std::collections::HashSet; -use std::collections::VecDeque; use std::io::IsTerminal; use std::io::Read; +use std::path::Path; use std::path::PathBuf; use supports_color::Stream; use tokio::sync::mpsc; @@ -101,8 +107,6 @@ use crate::event_processor::CodexStatus; use crate::event_processor::EventProcessor; use codex_core::default_client::set_default_client_residency_requirement; use codex_core::default_client::set_default_originator; -use codex_core::find_thread_path_by_id_str; -use codex_core::find_thread_path_by_name_str; const DEFAULT_ANALYTICS_ENABLED: bool = true; @@ -136,7 +140,6 @@ struct ExecRunArgs { in_process_start_args: InProcessClientStartArgs, command: Option, config: Config, - cursor_ansi: bool, dangerously_bypass_approvals_and_sandbox: bool, exec_span: tracing::Span, images: Vec, @@ -184,7 +187,6 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result prompt, output_schema: output_schema_path, config_overrides, - progress_cursor, } = cli; let (_stdout_with_ansi, stderr_with_ansi) = match color { @@ -195,25 +197,6 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result supports_color::on_cached(Stream::Stderr).is_some(), ), }; - let cursor_ansi = if progress_cursor { - true - } else { - match color { - cli::Color::Never => false, - cli::Color::Always => true, - cli::Color::Auto => { - if stderr_with_ansi || std::io::stderr().is_terminal() { - true - } else { - match std::env::var("TERM") { - Ok(term) => !term.is_empty() && term != "dumb", - Err(_) => false, - } - } - } - } - }; - // Build fmt layer (existing logging) to compose with OTEL layer. let default_level = "error"; @@ -287,18 +270,17 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result } }; - let cloud_auth_manager = AuthManager::shared( - codex_home.clone(), - /*enable_codex_api_key_env*/ false, - config_toml.cli_auth_credentials_store.unwrap_or_default(), - ); let chatgpt_base_url = config_toml .chatgpt_base_url .clone() .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); // TODO(gt): Make cloud requirements failures blocking once we can fail-closed. - let cloud_requirements = - cloud_requirements_loader(cloud_auth_manager, chatgpt_base_url, codex_home.clone()); + let cloud_requirements = cloud_requirements_loader_for_storage( + codex_home.clone(), + /*enable_codex_api_key_env*/ false, + config_toml.cli_auth_credentials_store.unwrap_or_default(), + chatgpt_base_url, + ); let run_cli_overrides = cli_kv_overrides.clone(); let run_loader_overrides = LoaderOverrides::default(); let run_cloud_requirements = cloud_requirements.clone(); @@ -455,7 +437,6 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result in_process_start_args, command, config, - cursor_ansi, dangerously_bypass_approvals_and_sandbox, exec_span: exec_span.clone(), images, @@ -477,7 +458,6 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { in_process_start_args, command, config, - cursor_ansi, dangerously_bypass_approvals_and_sandbox, exec_span, images, @@ -495,19 +475,10 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { true => Box::new(EventProcessorWithJsonOutput::new(last_message_file.clone())), _ => Box::new(EventProcessorWithHumanOutput::create_with_ansi( stderr_with_ansi, - cursor_ansi, &config, last_message_file.clone(), )), }; - let required_mcp_servers: HashSet = config - .mcp_servers - .get() - .iter() - .filter(|(_, server)| server.enabled && server.required) - .map(|(name, _)| name.clone()) - .collect(); - if oss { // We're in the oss section, so provider_id should be Some // Let's handle None case gracefully though just in case @@ -547,17 +518,16 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { anyhow::anyhow!("failed to initialize in-process app-server client: {err}") })?; - // Handle resume subcommand by resolving a rollout path and using explicit resume API. + // Handle resume subcommand through existing `thread/list` + `thread/resume` + // APIs so exec no longer reaches into rollout storage directly. let (primary_thread_id, fallback_session_configured) = if let Some(ExecCommand::Resume(args)) = command.as_ref() { - let resume_path = resolve_resume_path(&config, args).await?; - - if let Some(path) = resume_path { + if let Some(thread_id) = resolve_resume_thread_id(&client, &config, args).await? { let response: ThreadResumeResponse = send_request_with_response( &client, ClientRequest::ThreadResume { request_id: request_ids.next(), - params: thread_resume_params_from_config(&config, Some(path)), + params: thread_resume_params_from_config(&config, thread_id), }, "thread/resume", ) @@ -598,7 +568,6 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { }; let primary_thread_id_for_span = primary_thread_id.to_string(); - let mut buffered_events = VecDeque::new(); // Use the start/resume response as the authoritative bootstrap payload. // Waiting for a later streamed `SessionConfigured` event adds up to 10s of // avoidable startup latency on the in-process path. @@ -670,10 +639,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { // is using. event_processor.print_config_summary(&config, &prompt_summary, &session_configured); if !json_mode && let Some(message) = codex_core::config::system_bwrap_warning() { - let _ = event_processor.process_event(Event { - id: String::new(), - msg: EventMsg::Warning(codex_protocol::protocol::WarningEvent { message }), - }); + event_processor.process_warning(message); } info!("Codex initialized with event: {session_configured:?}"); @@ -734,6 +700,12 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { ) .await .map_err(anyhow::Error::msg)?; + let _ = event_processor.process_server_notification(ServerNotification::TurnStarted( + TurnStartedNotification { + thread_id: response.review_thread_id.clone(), + turn: response.turn.clone(), + }, + )); let task_id = response.turn.id; info!("Sent review request with event ID: {task_id}"); task_id @@ -748,34 +720,30 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let mut interrupt_channel_open = true; let primary_thread_id_for_requests = primary_thread_id.to_string(); loop { - let server_event = if let Some(event) = buffered_events.pop_front() { - Some(event) - } else { - tokio::select! { - maybe_interrupt = interrupt_rx.recv(), if interrupt_channel_open => { - if maybe_interrupt.is_none() { - interrupt_channel_open = false; - continue; - } - if let Err(err) = send_request_with_response::( - &client, - ClientRequest::TurnInterrupt { - request_id: request_ids.next(), - params: TurnInterruptParams { - thread_id: primary_thread_id_for_requests.clone(), - turn_id: task_id.clone(), - }, - }, - "turn/interrupt", - ) - .await - { - warn!("turn/interrupt failed: {err}"); - } + let server_event = tokio::select! { + maybe_interrupt = interrupt_rx.recv(), if interrupt_channel_open => { + if maybe_interrupt.is_none() { + interrupt_channel_open = false; continue; } - maybe_event = client.next_event() => maybe_event, + if let Err(err) = send_request_with_response::( + &client, + ClientRequest::TurnInterrupt { + request_id: request_ids.next(), + params: TurnInterruptParams { + thread_id: primary_thread_id_for_requests.clone(), + turn_id: task_id.clone(), + }, + }, + "turn/interrupt", + ) + .await + { + warn!("turn/interrupt failed: {err}"); + } + continue; } + maybe_event = client.next_event() => maybe_event, }; let Some(server_event) = server_event else { @@ -784,69 +752,39 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { match server_event { InProcessServerEvent::ServerRequest(request) => { - handle_server_request( - &client, - request, - &config, - &primary_thread_id_for_requests, - &mut error_seen, - ) - .await; + handle_server_request(&client, request, &mut error_seen).await; } - InProcessServerEvent::ServerNotification(notification) => { - if let ServerNotification::Error(payload) = ¬ification + InProcessServerEvent::ServerNotification(mut notification) => { + if let ServerNotification::Error(payload) = ¬ification { + if payload.thread_id == primary_thread_id_for_requests + && payload.turn_id == task_id + && !payload.will_retry + { + error_seen = true; + } + } else if let ServerNotification::TurnCompleted(payload) = ¬ification && payload.thread_id == primary_thread_id_for_requests - && payload.turn_id == task_id - && !payload.will_retry + && payload.turn.id == task_id + && matches!( + payload.turn.status, + codex_app_server_protocol::TurnStatus::Failed + | codex_app_server_protocol::TurnStatus::Interrupted + ) { error_seen = true; } - } - InProcessServerEvent::LegacyNotification(notification) => { - let decoded = match decode_legacy_notification(notification) { - Ok(event) => event, - Err(err) => { - warn!("{err}"); - continue; - } - }; - if decoded.conversation_id.as_deref() - != Some(primary_thread_id_for_requests.as_str()) - && decoded.conversation_id.is_some() - { - continue; - } - let event = decoded.event; - if matches!(event.msg, EventMsg::SessionConfigured(_)) { - continue; - } - if matches!(event.msg, EventMsg::Error(_)) { - // The legacy bridge still carries fatal turn failures for - // exec. Preserve the non-zero exit behavior until this - // path is fully replaced by typed server notifications. - error_seen = true; - } - match &event.msg { - EventMsg::TurnComplete(payload) => { - if payload.turn_id != task_id { - continue; - } - } - EventMsg::TurnAborted(payload) => { - if payload.turn_id.as_deref() != Some(task_id.as_str()) { - continue; - } - } - EventMsg::McpStartupUpdate(update) => { - if required_mcp_servers.contains(&update.server) - && let codex_protocol::protocol::McpStartupStatus::Failed { error } = - &update.status - { - error_seen = true; - eprintln!( - "Required MCP server '{}' failed to initialize: {error}", - update.server - ); + + maybe_backfill_turn_completed_items(&client, &mut request_ids, &mut notification) + .await; + + if should_process_notification( + ¬ification, + &primary_thread_id_for_requests, + &task_id, + ) { + match event_processor.process_server_notification(notification) { + CodexStatus::Running => {} + CodexStatus::InitiateShutdown => { if let Err(err) = request_shutdown( &client, &mut request_ids, @@ -859,37 +797,12 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { break; } } - _ => {} - } - - match event_processor.process_event(event) { - CodexStatus::Running => {} - CodexStatus::InitiateShutdown => { - if let Err(err) = request_shutdown( - &client, - &mut request_ids, - &primary_thread_id_for_requests, - ) - .await - { - warn!("thread/unsubscribe failed during shutdown: {err}"); - } - break; - } - CodexStatus::Shutdown => { - // `ShutdownComplete` does not identify which attached - // thread emitted it, so subagent shutdowns must not end - // the primary exec loop early. - } } } InProcessServerEvent::Lagged { skipped } => { let message = lagged_event_warning_message(skipped); warn!("{message}"); - let _ = event_processor.process_event(Event { - id: String::new(), - msg: EventMsg::Warning(codex_protocol::protocol::WarningEvent { message }), - }); + event_processor.process_warning(message); } } } @@ -936,10 +849,9 @@ fn thread_start_params_from_config(config: &Config) -> ThreadStartParams { } } -fn thread_resume_params_from_config(config: &Config, path: Option) -> ThreadResumeParams { +fn thread_resume_params_from_config(config: &Config, thread_id: String) -> ThreadResumeParams { ThreadResumeParams { - thread_id: "resume".to_string(), - path, + thread_id, model: config.model.clone(), model_provider: Some(config.model_provider_id.clone()), cwd: Some(config.cwd.to_string_lossy().to_string()), @@ -1017,20 +929,19 @@ fn session_configured_from_thread_resume_response( ) } +fn review_target_to_api(target: ReviewTarget) -> ApiReviewTarget { + match target { + ReviewTarget::UncommittedChanges => ApiReviewTarget::UncommittedChanges, + ReviewTarget::BaseBranch { branch } => ApiReviewTarget::BaseBranch { branch }, + ReviewTarget::Commit { sha, title } => ApiReviewTarget::Commit { sha, title }, + ReviewTarget::Custom { instructions } => ApiReviewTarget::Custom { instructions }, + } +} + #[expect( clippy::too_many_arguments, reason = "session mapping keeps explicit fields" )] -/// Synthesizes startup session metadata from `thread/start` or `thread/resume`. -/// -/// This is a compatibility bridge for the current in-process architecture. -/// Some session fields are not available synchronously from the start/resume -/// response, so callers must treat the result as a best-effort fallback until -/// a later `SessionConfigured` event proves otherwise. -/// TODO(architecture): stop synthesizing a partial `SessionConfiguredEvent` -/// here. Either return the authoritative session-configured payload from -/// `thread/start`/`thread/resume`, or introduce a smaller bootstrap type for -/// exec so this path cannot accidentally depend on placeholder fields. fn session_configured_from_thread_response( thread_id: &str, thread_name: Option, @@ -1040,7 +951,7 @@ fn session_configured_from_thread_response( service_tier: Option, approval_policy: AskForApproval, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, - sandbox_policy: codex_protocol::protocol::SandboxPolicy, + sandbox_policy: SandboxPolicy, cwd: PathBuf, reasoning_effort: Option, ) -> Result { @@ -1067,71 +978,261 @@ fn session_configured_from_thread_response( }) } -fn review_target_to_api(target: ReviewTarget) -> ApiReviewTarget { - match target { - ReviewTarget::UncommittedChanges => ApiReviewTarget::UncommittedChanges, - ReviewTarget::BaseBranch { branch } => ApiReviewTarget::BaseBranch { branch }, - ReviewTarget::Commit { sha, title } => ApiReviewTarget::Commit { sha, title }, - ReviewTarget::Custom { instructions } => ApiReviewTarget::Custom { instructions }, - } -} - -fn normalize_legacy_notification_method(method: &str) -> &str { - method.strip_prefix("codex/event/").unwrap_or(method) -} - fn lagged_event_warning_message(skipped: usize) -> String { format!("in-process app-server event stream lagged; dropped {skipped} events") } -struct DecodedLegacyNotification { - conversation_id: Option, - event: Event, +fn should_process_notification( + notification: &ServerNotification, + thread_id: &str, + turn_id: &str, +) -> bool { + match notification { + ServerNotification::ConfigWarning(_) | ServerNotification::DeprecationNotice(_) => true, + ServerNotification::Error(notification) => { + notification.thread_id == thread_id && notification.turn_id == turn_id + } + ServerNotification::HookCompleted(notification) => { + notification.thread_id == thread_id + && notification + .turn_id + .as_deref() + .is_none_or(|candidate| candidate == turn_id) + } + ServerNotification::HookStarted(notification) => { + notification.thread_id == thread_id + && notification + .turn_id + .as_deref() + .is_none_or(|candidate| candidate == turn_id) + } + ServerNotification::ItemCompleted(notification) => { + notification.thread_id == thread_id && notification.turn_id == turn_id + } + ServerNotification::ItemStarted(notification) => { + notification.thread_id == thread_id && notification.turn_id == turn_id + } + ServerNotification::ModelRerouted(notification) => { + notification.thread_id == thread_id && notification.turn_id == turn_id + } + ServerNotification::ThreadTokenUsageUpdated(notification) => { + notification.thread_id == thread_id && notification.turn_id == turn_id + } + ServerNotification::TurnCompleted(notification) => { + notification.thread_id == thread_id && notification.turn.id == turn_id + } + ServerNotification::TurnDiffUpdated(notification) => { + notification.thread_id == thread_id && notification.turn_id == turn_id + } + ServerNotification::TurnPlanUpdated(notification) => { + notification.thread_id == thread_id && notification.turn_id == turn_id + } + ServerNotification::TurnStarted(notification) => { + notification.thread_id == thread_id && notification.turn.id == turn_id + } + _ => false, + } } -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" - )); +async fn maybe_backfill_turn_completed_items( + client: &InProcessAppServerClient, + request_ids: &mut RequestIdSequencer, + notification: &mut ServerNotification, +) { + // In-process delivery may drop non-terminal item notifications under backpressure while still + // guaranteeing `turn/completed`. Because app-server currently emits that completion with an + // empty `turn.items`, exec does one last `thread/read` here so human/json output can recover + // the final message and reconcile any still-running items before shutdown. + let ServerNotification::TurnCompleted(payload) = notification else { + return; }; - let conversation_id = object - .get("conversationId") - .and_then(serde_json::Value::as_str) - .map(str::to_owned); - 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), - ); + if !payload.turn.items.is_empty() { + return; + } - 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: String::new(), - msg, + let response = send_request_with_response::( + client, + ClientRequest::ThreadRead { + request_id: request_ids.next(), + params: ThreadReadParams { + thread_id: payload.thread_id.clone(), + include_turns: true, + }, }, - }) + "thread/read", + ) + .await; + + match response { + Ok(response) => { + if let Some(items) = turn_items_for_thread(&response.thread, &payload.turn.id) { + payload.turn.items = items; + } + } + Err(err) => { + warn!("thread/read failed while backfilling turn items for turn completion: {err}"); + } + } +} + +fn turn_items_for_thread( + thread: &AppServerThread, + turn_id: &str, +) -> Option> { + thread + .turns + .iter() + .find(|turn| turn.id == turn_id) + .map(|turn| turn.items.clone()) +} + +fn all_thread_source_kinds() -> Vec { + vec![ + ThreadSourceKind::Cli, + ThreadSourceKind::VsCode, + ThreadSourceKind::Exec, + ThreadSourceKind::AppServer, + ThreadSourceKind::SubAgent, + ThreadSourceKind::SubAgentReview, + ThreadSourceKind::SubAgentCompact, + ThreadSourceKind::SubAgentThreadSpawn, + ThreadSourceKind::SubAgentOther, + ThreadSourceKind::Unknown, + ] +} + +async fn latest_thread_cwd(thread: &AppServerThread) -> PathBuf { + if let Some(path) = thread.path.as_deref() + && let Some(cwd) = parse_latest_turn_context_cwd(path).await + { + return cwd; + } + thread.cwd.clone() +} + +async fn parse_latest_turn_context_cwd(path: &Path) -> Option { + let text = tokio::fs::read_to_string(path).await.ok()?; + for line in text.lines().rev() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let Ok(rollout_line) = serde_json::from_str::(trimmed) else { + continue; + }; + if let RolloutItem::TurnContext(item) = rollout_line.item { + return Some(item.cwd); + } + } + None +} + +fn cwds_match(current_cwd: &Path, session_cwd: &Path) -> bool { + match ( + path_utils::normalize_for_path_comparison(current_cwd), + path_utils::normalize_for_path_comparison(session_cwd), + ) { + (Ok(current), Ok(session)) => current == session, + _ => current_cwd == session_cwd, + } +} + +async fn resolve_resume_thread_id( + client: &InProcessAppServerClient, + config: &Config, + args: &crate::cli::ResumeArgs, +) -> anyhow::Result> { + let model_providers = resume_lookup_model_providers(config, args); + + if args.last { + let mut cursor = None; + loop { + let response: ThreadListResponse = send_request_with_response( + client, + ClientRequest::ThreadList { + request_id: RequestId::Integer(0), + params: ThreadListParams { + cursor, + limit: Some(100), + sort_key: Some(ThreadSortKey::UpdatedAt), + model_providers: model_providers.clone(), + source_kinds: Some(all_thread_source_kinds()), + archived: Some(false), + cwd: None, + search_term: None, + }, + }, + "thread/list", + ) + .await + .map_err(anyhow::Error::msg)?; + for thread in response.data { + if args.all || cwds_match(config.cwd.as_path(), &latest_thread_cwd(&thread).await) { + return Ok(Some(thread.id)); + } + } + let Some(next_cursor) = response.next_cursor else { + return Ok(None); + }; + cursor = Some(next_cursor); + } + } + + let Some(session_id) = args.session_id.as_deref() else { + return Ok(None); + }; + if Uuid::parse_str(session_id).is_ok() { + return Ok(Some(session_id.to_string())); + } + + let mut cursor = None; + loop { + let response: ThreadListResponse = send_request_with_response( + client, + ClientRequest::ThreadList { + request_id: RequestId::Integer(0), + params: ThreadListParams { + cursor, + limit: Some(100), + sort_key: Some(ThreadSortKey::UpdatedAt), + model_providers: model_providers.clone(), + source_kinds: Some(all_thread_source_kinds()), + archived: Some(false), + cwd: None, + // Thread names are attached separately from rollout titles, so name + // resolution must scan the filtered list client-side instead of relying + // on the backend `search_term` filter. + search_term: None, + }, + }, + "thread/list", + ) + .await + .map_err(anyhow::Error::msg)?; + for thread in response.data { + if thread.name.as_deref() != Some(session_id) { + continue; + } + if args.all || cwds_match(config.cwd.as_path(), &latest_thread_cwd(&thread).await) { + return Ok(Some(thread.id)); + } + } + let Some(next_cursor) = response.next_cursor else { + return Ok(None); + }; + cursor = Some(next_cursor); + } +} + +fn resume_lookup_model_providers( + config: &Config, + args: &crate::cli::ResumeArgs, +) -> Option> { + if args.last { + Some(vec![config.model_provider_id.clone()]) + } else { + None + } } fn canceled_mcp_server_elicitation_response() -> Result { @@ -1205,8 +1306,6 @@ fn server_request_method_name(request: &ServerRequest) -> String { async fn handle_server_request( client: &InProcessAppServerClient, request: ServerRequest, - config: &Config, - _thread_id: &str, error_seen: &mut bool, ) { let method = server_request_method_name(&request); @@ -1228,50 +1327,6 @@ async fn handle_server_request( Err(err) => Err(err), } } - ServerRequest::ChatgptAuthTokensRefresh { request_id, params } => { - let refresh_result = tokio::task::spawn_blocking({ - let config = config.clone(); - move || local_external_chatgpt_tokens(&config) - }) - .await; - - match refresh_result { - Err(err) => { - reject_server_request( - client, - request_id, - &method, - format!("local chatgpt auth refresh task failed in exec: {err}"), - ) - .await - } - Ok(Err(reason)) => reject_server_request(client, request_id, &method, reason).await, - Ok(Ok(response)) => { - if let Some(previous_account_id) = params.previous_account_id.as_deref() - && previous_account_id != response.chatgpt_account_id - { - warn!( - "local auth refresh account mismatch: expected `{previous_account_id}`, got `{}`", - response.chatgpt_account_id - ); - } - match serde_json::to_value(response) { - Ok(value) => { - resolve_server_request( - client, - request_id, - value, - "account/chatgptAuthTokens/refresh", - ) - .await - } - Err(err) => Err(format!( - "failed to serialize chatgpt auth refresh response: {err}" - )), - } - } - } - } ServerRequest::CommandExecutionRequestApproval { request_id, params } => { reject_server_request( client, @@ -1320,6 +1375,15 @@ async fn handle_server_request( ) .await } + ServerRequest::ChatgptAuthTokensRefresh { request_id, .. } => { + reject_server_request( + client, + request_id, + &method, + "chatgpt auth token refresh is not supported in exec mode".to_string(), + ) + .await + } ServerRequest::ApplyPatchApproval { request_id, params } => { reject_server_request( client, @@ -1364,91 +1428,6 @@ async fn handle_server_request( } } -fn local_external_chatgpt_tokens( - config: &Config, -) -> Result { - let auth_manager = AuthManager::shared( - config.codex_home.clone(), - /*enable_codex_api_key_env*/ false, - config.cli_auth_credentials_store_mode, - ); - auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); - auth_manager.reload(); - - 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 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, - }) -} - -async fn resolve_resume_path( - config: &Config, - args: &crate::cli::ResumeArgs, -) -> anyhow::Result> { - if args.last { - let default_provider_filter = vec![config.model_provider_id.clone()]; - let filter_cwd = if args.all { - None - } else { - Some(config.cwd.as_path()) - }; - match codex_core::RolloutRecorder::find_latest_thread_path( - config, - /*page_size*/ 1, - /*cursor*/ None, - codex_core::ThreadSortKey::UpdatedAt, - &[], - Some(default_provider_filter.as_slice()), - &config.model_provider_id, - filter_cwd, - ) - .await - { - Ok(path) => Ok(path), - Err(e) => { - error!("Error listing threads: {e}"); - Ok(None) - } - } - } else if let Some(id_str) = args.session_id.as_deref() { - if Uuid::parse_str(id_str).is_ok() { - let path = find_thread_path_by_id_str(&config.codex_home, id_str).await?; - Ok(path) - } else { - let path = find_thread_path_by_name_str(&config.codex_home, id_str).await?; - Ok(path) - } - } else { - Ok(None) - } -} - fn load_output_schema(path: Option) -> Option { let path = path?; @@ -1806,27 +1785,92 @@ mod tests { ); } - #[test] - fn decode_legacy_notification_preserves_conversation_id() { - let decoded = decode_legacy_notification(JSONRPCNotification { - method: "codex/event/error".to_string(), - params: Some(serde_json::json!({ - "conversationId": "thread-123", - "msg": { - "message": "boom" - } - })), - }) - .expect("legacy notification should decode"); + #[tokio::test] + async fn resume_lookup_model_providers_filters_only_last_lookup() { + let codex_home = tempdir().expect("create temp codex home"); + let cwd = tempdir().expect("create temp cwd"); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(cwd.path().to_path_buf())) + .build() + .await + .expect("build default config"); + config.model_provider_id = "test-provider".to_string(); - assert_eq!(decoded.conversation_id.as_deref(), Some("thread-123")); - assert!(matches!( - decoded.event.msg, - EventMsg::Error(codex_protocol::protocol::ErrorEvent { - message, - codex_error_info: None, - }) if message == "boom" - )); + let last_args = crate::cli::ResumeArgs { + session_id: None, + last: true, + all: false, + images: vec![], + prompt: None, + }; + let named_args = crate::cli::ResumeArgs { + session_id: Some("named-session".to_string()), + last: false, + all: false, + images: vec![], + prompt: None, + }; + + assert_eq!( + resume_lookup_model_providers(&config, &last_args), + Some(vec!["test-provider".to_string()]) + ); + assert_eq!(resume_lookup_model_providers(&config, &named_args), None); + } + + #[test] + fn turn_items_for_thread_returns_matching_turn_items() { + let thread = AppServerThread { + id: "thread-1".to_string(), + preview: String::new(), + ephemeral: false, + model_provider: "openai".to_string(), + created_at: 0, + updated_at: 0, + status: codex_app_server_protocol::ThreadStatus::Idle, + path: None, + cwd: PathBuf::from("/tmp/project"), + cli_version: "0.0.0-test".to_string(), + source: codex_app_server_protocol::SessionSource::Exec, + agent_nickname: None, + agent_role: None, + git_info: None, + name: None, + turns: vec![ + codex_app_server_protocol::Turn { + id: "turn-1".to_string(), + items: vec![AppServerThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "hello".to_string(), + phase: None, + memory_citation: None, + }], + status: codex_app_server_protocol::TurnStatus::Completed, + error: None, + }, + codex_app_server_protocol::Turn { + id: "turn-2".to_string(), + items: vec![AppServerThreadItem::Plan { + id: "plan-1".to_string(), + text: "ship it".to_string(), + }], + status: codex_app_server_protocol::TurnStatus::Completed, + error: None, + }, + ], + }; + + assert_eq!( + turn_items_for_thread(&thread, "turn-1"), + Some(vec![AppServerThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "hello".to_string(), + phase: None, + memory_citation: None, + }]) + ); + assert_eq!(turn_items_for_thread(&thread, "missing-turn"), None); } #[test] diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index e9f295337..58579ccc2 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -1,3 +1,44 @@ +use std::path::PathBuf; + +use codex_app_server_protocol::CollabAgentState as ApiCollabAgentState; +use codex_app_server_protocol::CollabAgentStatus as ApiCollabAgentStatus; +use codex_app_server_protocol::CollabAgentTool; +use codex_app_server_protocol::CollabAgentToolCallStatus as ApiCollabAgentToolCallStatus; +use codex_app_server_protocol::CommandAction; +use codex_app_server_protocol::CommandExecutionSource; +use codex_app_server_protocol::CommandExecutionStatus as ApiCommandExecutionStatus; +use codex_app_server_protocol::ErrorNotification; +use codex_app_server_protocol::FileUpdateChange as ApiFileUpdateChange; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::McpToolCallError; +use codex_app_server_protocol::McpToolCallResult; +use codex_app_server_protocol::McpToolCallStatus as ApiMcpToolCallStatus; +use codex_app_server_protocol::PatchApplyStatus as ApiPatchApplyStatus; +use codex_app_server_protocol::PatchChangeKind as ApiPatchChangeKind; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::TokenUsageBreakdown; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError; +use codex_app_server_protocol::TurnPlanStep; +use codex_app_server_protocol::TurnPlanStepStatus; +use codex_app_server_protocol::TurnPlanUpdatedNotification; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus; +use codex_app_server_protocol::WebSearchAction as ApiWebSearchAction; +use codex_protocol::ThreadId; +use codex_protocol::models::WebSearchAction; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionConfiguredEvent; +use pretty_assertions::assert_eq; +use serde_json::json; + +use codex_exec::event_processor_with_jsonl_output::CodexStatus; +use codex_exec::event_processor_with_jsonl_output::CollectedThreadEvents; use codex_exec::event_processor_with_jsonl_output::EventProcessorWithJsonOutput; use codex_exec::exec_events::AgentMessageItem; use codex_exec::exec_events::CollabAgentState; @@ -8,6 +49,8 @@ use codex_exec::exec_events::CollabToolCallStatus; use codex_exec::exec_events::CommandExecutionItem; use codex_exec::exec_events::CommandExecutionStatus; use codex_exec::exec_events::ErrorItem; +use codex_exec::exec_events::FileChangeItem; +use codex_exec::exec_events::FileUpdateChange as ExecFileUpdateChange; use codex_exec::exec_events::ItemCompletedEvent; use codex_exec::exec_events::ItemStartedEvent; use codex_exec::exec_events::ItemUpdatedEvent; @@ -20,1299 +63,1509 @@ use codex_exec::exec_events::PatchChangeKind; use codex_exec::exec_events::ReasoningItem; use codex_exec::exec_events::ThreadErrorEvent; use codex_exec::exec_events::ThreadEvent; -use codex_exec::exec_events::ThreadItem; +use codex_exec::exec_events::ThreadItem as ExecThreadItem; use codex_exec::exec_events::ThreadItemDetails; use codex_exec::exec_events::ThreadStartedEvent; -use codex_exec::exec_events::TodoItem as ExecTodoItem; -use codex_exec::exec_events::TodoListItem as ExecTodoListItem; +use codex_exec::exec_events::TodoItem; +use codex_exec::exec_events::TodoListItem; use codex_exec::exec_events::TurnCompletedEvent; use codex_exec::exec_events::TurnFailedEvent; use codex_exec::exec_events::TurnStartedEvent; use codex_exec::exec_events::Usage; use codex_exec::exec_events::WebSearchItem; -use codex_protocol::ThreadId; -use codex_protocol::config_types::ModeKind; -use codex_protocol::mcp::CallToolResult; -use codex_protocol::models::WebSearchAction; -use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; -use codex_protocol::plan_tool::PlanItemArg; -use codex_protocol::plan_tool::StepStatus; -use codex_protocol::plan_tool::UpdatePlanArgs; -use codex_protocol::protocol::AgentMessageEvent; -use codex_protocol::protocol::AgentReasoningEvent; -use codex_protocol::protocol::AgentStatus; -use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::CodexErrorInfo; -use codex_protocol::protocol::CollabAgentSpawnBeginEvent; -use codex_protocol::protocol::CollabAgentSpawnEndEvent; -use codex_protocol::protocol::CollabWaitingEndEvent; -use codex_protocol::protocol::ErrorEvent; -use codex_protocol::protocol::Event; -use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::ExecCommandBeginEvent; -use codex_protocol::protocol::ExecCommandEndEvent; -use codex_protocol::protocol::ExecCommandOutputDeltaEvent; -use codex_protocol::protocol::ExecCommandSource; -use codex_protocol::protocol::ExecCommandStatus as CoreExecCommandStatus; -use codex_protocol::protocol::ExecOutputStream; -use codex_protocol::protocol::FileChange; -use codex_protocol::protocol::McpInvocation; -use codex_protocol::protocol::McpToolCallBeginEvent; -use codex_protocol::protocol::McpToolCallEndEvent; -use codex_protocol::protocol::PatchApplyBeginEvent; -use codex_protocol::protocol::PatchApplyEndEvent; -use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; -use codex_protocol::protocol::SandboxPolicy; -use codex_protocol::protocol::SessionConfiguredEvent; -use codex_protocol::protocol::WarningEvent; -use codex_protocol::protocol::WebSearchBeginEvent; -use codex_protocol::protocol::WebSearchEndEvent; -use pretty_assertions::assert_eq; -use rmcp::model::Content; -use serde_json::json; -use std::path::PathBuf; -use std::time::Duration; - -fn event(id: &str, msg: EventMsg) -> Event { - Event { - id: id.to_string(), - msg, - } -} #[test] -fn session_configured_produces_thread_started_event() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let session_id = - codex_protocol::ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8").unwrap(); - let rollout_path = PathBuf::from("/tmp/rollout.json"); - let ev = event( - "e1", - EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id, - forked_from_id: None, - thread_name: None, - model: "codex-mini-latest".to_string(), - model_provider_id: "test-provider".to_string(), - service_tier: None, - approval_policy: AskForApproval::Never, - approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - cwd: PathBuf::from("/home/user/project"), - reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, - initial_messages: None, - network_proxy: None, - rollout_path: Some(rollout_path), - }), - ); - let out = ep.collect_thread_events(&ev); - assert_eq!( - out, - vec![ThreadEvent::ThreadStarted(ThreadStartedEvent { - thread_id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(), - })] - ); -} - -#[test] -fn task_started_produces_turn_started_event() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let out = ep.collect_thread_events(&event( - "t1", - EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent { - turn_id: "turn-1".to_string(), - model_context_window: Some(32_000), - collaboration_mode_kind: ModeKind::Default, - }), - )); - - assert_eq!(out, vec![ThreadEvent::TurnStarted(TurnStartedEvent {})]); -} - -#[test] -fn web_search_end_emits_item_completed() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let query = "rust async await".to_string(); - let action = WebSearchAction::Search { - query: Some(query.clone()), - queries: None, - }; - let out = ep.collect_thread_events(&event( - "w1", - EventMsg::WebSearchEnd(WebSearchEndEvent { - call_id: "call-123".to_string(), - query: query.clone(), - action: action.clone(), - }), - )); +fn map_todo_items_preserves_text_and_completion_state() { + let items = EventProcessorWithJsonOutput::map_todo_items(&[ + TurnPlanStep { + step: "inspect bootstrap".to_string(), + status: TurnPlanStepStatus::InProgress, + }, + TurnPlanStep { + step: "drop legacy notifications".to_string(), + status: TurnPlanStepStatus::Completed, + }, + ]); assert_eq!( - out, - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::WebSearch(WebSearchItem { - id: "call-123".to_string(), - query, - action, - }), - }, - })] - ); -} - -#[test] -fn web_search_begin_emits_item_started() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let out = ep.collect_thread_events(&event( - "w0", - EventMsg::WebSearchBegin(WebSearchBeginEvent { - call_id: "call-0".to_string(), - }), - )); - - assert_eq!(out.len(), 1); - let ThreadEvent::ItemStarted(ItemStartedEvent { item }) = &out[0] else { - panic!("expected ItemStarted"); - }; - assert!(item.id.starts_with("item_")); - assert_eq!( - item.details, - ThreadItemDetails::WebSearch(WebSearchItem { - id: "call-0".to_string(), - query: String::new(), - action: WebSearchAction::Other, - }) - ); -} - -#[test] -fn web_search_begin_then_end_reuses_item_id() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let begin = ep.collect_thread_events(&event( - "w0", - EventMsg::WebSearchBegin(WebSearchBeginEvent { - call_id: "call-1".to_string(), - }), - )); - let ThreadEvent::ItemStarted(ItemStartedEvent { item: started_item }) = &begin[0] else { - panic!("expected ItemStarted"); - }; - let action = WebSearchAction::Search { - query: Some("rust async await".to_string()), - queries: None, - }; - let end = ep.collect_thread_events(&event( - "w1", - EventMsg::WebSearchEnd(WebSearchEndEvent { - call_id: "call-1".to_string(), - query: "rust async await".to_string(), - action: action.clone(), - }), - )); - let ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: completed_item, - }) = &end[0] - else { - panic!("expected ItemCompleted"); - }; - - assert_eq!(completed_item.id, started_item.id); - assert_eq!( - completed_item.details, - ThreadItemDetails::WebSearch(WebSearchItem { - id: "call-1".to_string(), - query: "rust async await".to_string(), - action, - }) - ); -} - -#[test] -fn plan_update_emits_todo_list_started_updated_and_completed() { - let mut ep = EventProcessorWithJsonOutput::new(None); - - // First plan update => item.started (todo_list) - let first = event( - "p1", - EventMsg::PlanUpdate(UpdatePlanArgs { - explanation: None, - plan: vec![ - PlanItemArg { - step: "step one".to_string(), - status: StepStatus::Pending, - }, - PlanItemArg { - step: "step two".to_string(), - status: StepStatus::InProgress, - }, - ], - }), - ); - let out_first = ep.collect_thread_events(&first); - assert_eq!( - out_first, - vec![ThreadEvent::ItemStarted(ItemStartedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::TodoList(ExecTodoListItem { - items: vec![ - ExecTodoItem { - text: "step one".to_string(), - completed: false - }, - ExecTodoItem { - text: "step two".to_string(), - completed: false - }, - ], - }), - }, - })] - ); - - // Second plan update in same turn => item.updated (same id) - let second = event( - "p2", - EventMsg::PlanUpdate(UpdatePlanArgs { - explanation: None, - plan: vec![ - PlanItemArg { - step: "step one".to_string(), - status: StepStatus::Completed, - }, - PlanItemArg { - step: "step two".to_string(), - status: StepStatus::InProgress, - }, - ], - }), - ); - let out_second = ep.collect_thread_events(&second); - assert_eq!( - out_second, - vec![ThreadEvent::ItemUpdated(ItemUpdatedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::TodoList(ExecTodoListItem { - items: vec![ - ExecTodoItem { - text: "step one".to_string(), - completed: true - }, - ExecTodoItem { - text: "step two".to_string(), - completed: false - }, - ], - }), - }, - })] - ); - - // Task completes => item.completed (same id, latest state) - let complete = event( - "p3", - EventMsg::TurnComplete(codex_protocol::protocol::TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: None, - }), - ); - let out_complete = ep.collect_thread_events(&complete); - assert_eq!( - out_complete, + items, vec![ - ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::TodoList(ExecTodoListItem { - items: vec![ - ExecTodoItem { - text: "step one".to_string(), - completed: true - }, - ExecTodoItem { - text: "step two".to_string(), - completed: false - }, - ], - }), - }, - }), - ThreadEvent::TurnCompleted(TurnCompletedEvent { - usage: Usage::default(), - }), + TodoItem { + text: "inspect bootstrap".to_string(), + completed: false, + }, + TodoItem { + text: "drop legacy notifications".to_string(), + completed: true, + }, ] ); } #[test] -fn mcp_tool_call_begin_and_end_emit_item_events() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let invocation = McpInvocation { - server: "server_a".to_string(), - tool: "tool_x".to_string(), - arguments: Some(json!({ "key": "value" })), +fn session_configured_produces_thread_started_event() { + let session_configured = SessionConfiguredEvent { + session_id: ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8") + .expect("thread id should parse"), + forked_from_id: None, + thread_name: None, + model: "codex-mini-latest".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: PathBuf::from("/tmp/project"), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: None, }; - let begin = event( - "m1", - EventMsg::McpToolCallBegin(McpToolCallBeginEvent { - call_id: "call-1".to_string(), - invocation: invocation.clone(), - }), - ); - let begin_events = ep.collect_thread_events(&begin); assert_eq!( - begin_events, - vec![ThreadEvent::ItemStarted(ItemStartedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::McpToolCall(McpToolCallItem { - server: "server_a".to_string(), - tool: "tool_x".to_string(), - arguments: json!({ "key": "value" }), - result: None, - error: None, - status: McpToolCallStatus::InProgress, - }), + EventProcessorWithJsonOutput::thread_started_event(&session_configured), + ThreadEvent::ThreadStarted(ThreadStartedEvent { + thread_id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(), + }) + ); +} + +#[test] +fn turn_started_emits_turn_started_event() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let collected = + processor.collect_thread_events(ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::InProgress, + error: None, }, - })] + })); + + assert_eq!( + collected, + CollectedThreadEvents { + events: vec![ThreadEvent::TurnStarted(TurnStartedEvent {})], + status: CodexStatus::Running, + } + ); +} + +#[test] +fn command_execution_started_and_completed_translate_to_thread_events() { + let mut processor = EventProcessorWithJsonOutput::new(None); + let command_item = ThreadItem::CommandExecution { + id: "cmd-1".to_string(), + command: "ls".to_string(), + cwd: PathBuf::from("/tmp/project"), + process_id: Some("123".to_string()), + source: CommandExecutionSource::UserShell, + status: ApiCommandExecutionStatus::InProgress, + command_actions: Vec::::new(), + aggregated_output: None, + exit_code: None, + duration_ms: None, + }; + + let started = + processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification { + item: command_item, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + })); + assert_eq!( + started, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemStarted(ItemStartedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::CommandExecution(CommandExecutionItem { + command: "ls".to_string(), + aggregated_output: String::new(), + exit_code: None, + status: CommandExecutionStatus::InProgress, + }), + }, + })], + status: CodexStatus::Running, + } ); - let end = event( - "m2", - EventMsg::McpToolCallEnd(McpToolCallEndEvent { - call_id: "call-1".to_string(), - invocation, - duration: Duration::from_secs(1), - result: Ok(CallToolResult { - content: Vec::new(), - is_error: None, - structured_content: None, - meta: None, - }), - }), - ); - let end_events = ep.collect_thread_events(&end); + let completed = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::CommandExecution { + id: "cmd-1".to_string(), + command: "ls".to_string(), + cwd: PathBuf::from("/tmp/project"), + process_id: Some("123".to_string()), + source: CommandExecutionSource::UserShell, + status: ApiCommandExecutionStatus::Completed, + command_actions: Vec::::new(), + aggregated_output: Some("a.txt\n".to_string()), + exit_code: Some(0), + duration_ms: Some(3), + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); assert_eq!( - end_events, - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::McpToolCall(McpToolCallItem { - server: "server_a".to_string(), - tool: "tool_x".to_string(), - arguments: json!({ "key": "value" }), - result: Some(McpToolCallItemResult { - content: Vec::new(), - structured_content: None, + completed, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::CommandExecution(CommandExecutionItem { + command: "ls".to_string(), + aggregated_output: "a.txt\n".to_string(), + exit_code: Some(0), + status: CommandExecutionStatus::Completed, }), - error: None, - status: McpToolCallStatus::Completed, + }, + })], + status: CodexStatus::Running, + } + ); +} + +#[test] +fn empty_reasoning_items_are_ignored() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::Reasoning { + id: "reasoning-1".to_string(), + summary: Vec::new(), + content: vec!["raw reasoning".to_string()], + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!( + collected, + CollectedThreadEvents { + events: Vec::new(), + status: CodexStatus::Running, + } + ); +} + +#[test] +fn unsupported_items_do_not_consume_synthetic_ids() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let ignored = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::Plan { + id: "plan-1".to_string(), + text: "ignored plan".to_string(), + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!( + ignored, + CollectedThreadEvents { + events: Vec::new(), + status: CodexStatus::Running, + } + ); + + let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::AgentMessage { + id: "message-1".to_string(), + text: "hello".to_string(), + phase: None, + memory_citation: None, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!( + collected, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::AgentMessage(AgentMessageItem { + text: "hello".to_string(), + }), + }, + })], + status: CodexStatus::Running, + } + ); +} + +#[test] +fn reasoning_items_emit_summary_not_raw_content() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::Reasoning { + id: "reasoning-1".to_string(), + summary: vec!["safe summary".to_string()], + content: vec!["raw reasoning".to_string()], + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!( + collected, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::Reasoning(ReasoningItem { + text: "safe summary".to_string(), + }), + }, + })], + status: CodexStatus::Running, + } + ); +} + +#[test] +fn web_search_completion_preserves_query_and_action() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::WebSearch { + id: "search-1".to_string(), + query: "rust async await".to_string(), + action: Some(ApiWebSearchAction::Search { + query: Some("rust async await".to_string()), + queries: None, }), }, - })] + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!( + collected, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::WebSearch(WebSearchItem { + id: "search-1".to_string(), + query: "rust async await".to_string(), + action: WebSearchAction::Search { + query: Some("rust async await".to_string()), + queries: None, + }, + }), + }, + })], + status: CodexStatus::Running, + } + ); +} + +#[test] +fn web_search_start_and_completion_reuse_item_id() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let started = + processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification { + item: ThreadItem::WebSearch { + id: "search-1".to_string(), + query: String::new(), + action: None, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + })); + + let completed = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::WebSearch { + id: "search-1".to_string(), + query: "rust async await".to_string(), + action: Some(ApiWebSearchAction::Search { + query: Some("rust async await".to_string()), + queries: None, + }), + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!( + started, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemStarted(ItemStartedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::WebSearch(WebSearchItem { + id: "search-1".to_string(), + query: String::new(), + action: WebSearchAction::Other, + }), + }, + })], + status: CodexStatus::Running, + } + ); + assert_eq!( + completed, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::WebSearch(WebSearchItem { + id: "search-1".to_string(), + query: "rust async await".to_string(), + action: WebSearchAction::Search { + query: Some("rust async await".to_string()), + queries: None, + }, + }), + }, + })], + status: CodexStatus::Running, + } + ); +} + +#[test] +fn mcp_tool_call_begin_and_end_emit_item_events() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let started = + processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification { + item: ThreadItem::McpToolCall { + id: "mcp-1".to_string(), + server: "server_a".to_string(), + tool: "tool_x".to_string(), + status: ApiMcpToolCallStatus::InProgress, + arguments: json!({ "key": "value" }), + result: None, + error: None, + duration_ms: None, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + })); + let completed = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::McpToolCall { + id: "mcp-1".to_string(), + server: "server_a".to_string(), + tool: "tool_x".to_string(), + status: ApiMcpToolCallStatus::Completed, + arguments: json!({ "key": "value" }), + result: Some(McpToolCallResult { + content: Vec::new(), + structured_content: None, + }), + error: None, + duration_ms: Some(1_000), + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!( + started, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemStarted(ItemStartedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::McpToolCall(McpToolCallItem { + server: "server_a".to_string(), + tool: "tool_x".to_string(), + arguments: json!({ "key": "value" }), + result: None, + error: None, + status: McpToolCallStatus::InProgress, + }), + }, + })], + status: CodexStatus::Running, + } + ); + assert_eq!( + completed, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::McpToolCall(McpToolCallItem { + server: "server_a".to_string(), + tool: "tool_x".to_string(), + arguments: json!({ "key": "value" }), + result: Some(McpToolCallItemResult { + content: Vec::new(), + structured_content: None, + }), + error: None, + status: McpToolCallStatus::Completed, + }), + }, + })], + status: CodexStatus::Running, + } ); } #[test] fn mcp_tool_call_failure_sets_failed_status() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let invocation = McpInvocation { - server: "server_b".to_string(), - tool: "tool_y".to_string(), - arguments: Some(json!({ "param": 42 })), - }; + let mut processor = EventProcessorWithJsonOutput::new(None); - let begin = event( - "m3", - EventMsg::McpToolCallBegin(McpToolCallBeginEvent { - call_id: "call-2".to_string(), - invocation: invocation.clone(), - }), - ); - ep.collect_thread_events(&begin); - - let end = event( - "m4", - EventMsg::McpToolCallEnd(McpToolCallEndEvent { - call_id: "call-2".to_string(), - invocation, - duration: Duration::from_millis(5), - result: Err("tool exploded".to_string()), - }), - ); - let events = ep.collect_thread_events(&end); - assert_eq!( - events, - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::McpToolCall(McpToolCallItem { - server: "server_b".to_string(), - tool: "tool_y".to_string(), - arguments: json!({ "param": 42 }), - result: None, - error: Some(McpToolCallItemError { - message: "tool exploded".to_string(), - }), - status: McpToolCallStatus::Failed, + let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::McpToolCall { + id: "mcp-2".to_string(), + server: "server_b".to_string(), + tool: "tool_y".to_string(), + status: ApiMcpToolCallStatus::Failed, + arguments: json!({ "param": 42 }), + result: None, + error: Some(McpToolCallError { + message: "tool exploded".to_string(), }), + duration_ms: Some(5), }, - })] + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!( + collected, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::McpToolCall(McpToolCallItem { + server: "server_b".to_string(), + tool: "tool_y".to_string(), + arguments: json!({ "param": 42 }), + result: None, + error: Some(McpToolCallItemError { + message: "tool exploded".to_string(), + }), + status: McpToolCallStatus::Failed, + }), + }, + })], + status: CodexStatus::Running, + } ); } #[test] fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let invocation = McpInvocation { - server: "server_c".to_string(), - tool: "tool_z".to_string(), - arguments: None, - }; + let mut processor = EventProcessorWithJsonOutput::new(None); - let begin = event( - "m5", - EventMsg::McpToolCallBegin(McpToolCallBeginEvent { - call_id: "call-3".to_string(), - invocation: invocation.clone(), - }), - ); - let begin_events = ep.collect_thread_events(&begin); - assert_eq!( - begin_events, - vec![ThreadEvent::ItemStarted(ItemStartedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::McpToolCall(McpToolCallItem { - server: "server_c".to_string(), - tool: "tool_z".to_string(), - arguments: serde_json::Value::Null, - result: None, - error: None, - status: McpToolCallStatus::InProgress, - }), + let started = + processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification { + item: ThreadItem::McpToolCall { + id: "mcp-3".to_string(), + server: "server_c".to_string(), + tool: "tool_z".to_string(), + status: ApiMcpToolCallStatus::InProgress, + arguments: serde_json::Value::Null, + result: None, + error: None, + duration_ms: None, }, - })] - ); + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + })); + let completed = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::McpToolCall { + id: "mcp-3".to_string(), + server: "server_c".to_string(), + tool: "tool_z".to_string(), + status: ApiMcpToolCallStatus::Completed, + arguments: serde_json::Value::Null, + result: Some(McpToolCallResult { + content: vec![json!({ + "type": "text", + "text": "done", + })], + structured_content: Some(json!({ "status": "ok" })), + }), + error: None, + duration_ms: Some(10), + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); - let end = event( - "m6", - EventMsg::McpToolCallEnd(McpToolCallEndEvent { - call_id: "call-3".to_string(), - invocation, - duration: Duration::from_millis(10), - result: Ok(CallToolResult { - content: vec![serde_json::to_value(Content::text("done")).unwrap()], - is_error: None, - structured_content: Some(json!({ "status": "ok" })), - meta: None, - }), - }), - ); - let events = ep.collect_thread_events(&end); assert_eq!( - events, - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::McpToolCall(McpToolCallItem { - server: "server_c".to_string(), - tool: "tool_z".to_string(), - arguments: serde_json::Value::Null, - result: Some(McpToolCallItemResult { - content: vec![serde_json::to_value(Content::text("done")).unwrap()], - structured_content: Some(json!({ "status": "ok" })), + started, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemStarted(ItemStartedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::McpToolCall(McpToolCallItem { + server: "server_c".to_string(), + tool: "tool_z".to_string(), + arguments: serde_json::Value::Null, + result: None, + error: None, + status: McpToolCallStatus::InProgress, }), - error: None, - status: McpToolCallStatus::Completed, - }), - }, - })] + }, + })], + status: CodexStatus::Running, + } + ); + assert_eq!( + completed, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::McpToolCall(McpToolCallItem { + server: "server_c".to_string(), + tool: "tool_z".to_string(), + arguments: serde_json::Value::Null, + result: Some(McpToolCallItemResult { + content: vec![json!({ + "type": "text", + "text": "done", + })], + structured_content: Some(json!({ "status": "ok" })), + }), + error: None, + status: McpToolCallStatus::Completed, + }), + }, + })], + status: CodexStatus::Running, + } ); } #[test] fn collab_spawn_begin_and_end_emit_item_events() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let sender_thread_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8").unwrap(); - let new_thread_id = ThreadId::from_string("9e107d9d-372b-4b8c-a2a4-1d9bb3fce0c1").unwrap(); - let prompt = "draft a plan".to_string(); + let mut processor = EventProcessorWithJsonOutput::new(None); - let begin = event( - "c1", - EventMsg::CollabAgentSpawnBegin(CollabAgentSpawnBeginEvent { - call_id: "call-10".to_string(), - sender_thread_id, - prompt: prompt.clone(), - model: "gpt-5".to_string(), - reasoning_effort: ReasoningEffortConfig::default(), - }), - ); - let begin_events = ep.collect_thread_events(&begin); - assert_eq!( - begin_events, - vec![ThreadEvent::ItemStarted(ItemStartedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::CollabToolCall(CollabToolCallItem { - tool: CollabTool::SpawnAgent, - sender_thread_id: sender_thread_id.to_string(), - receiver_thread_ids: Vec::new(), - prompt: Some(prompt.clone()), - agents_states: std::collections::HashMap::new(), - status: CollabToolCallStatus::InProgress, - }), + let started = + processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification { + item: ThreadItem::CollabAgentToolCall { + id: "collab-1".to_string(), + tool: CollabAgentTool::SpawnAgent, + status: ApiCollabAgentToolCallStatus::InProgress, + sender_thread_id: "thread-parent".to_string(), + receiver_thread_ids: Vec::new(), + prompt: Some("draft a plan".to_string()), + model: Some("gpt-5".to_string()), + reasoning_effort: None, + agents_states: std::collections::HashMap::new(), }, - })] - ); - - let end = event( - "c2", - EventMsg::CollabAgentSpawnEnd(CollabAgentSpawnEndEvent { - call_id: "call-10".to_string(), - sender_thread_id, - new_thread_id: Some(new_thread_id), - new_agent_nickname: None, - new_agent_role: None, - prompt: prompt.clone(), - model: "gpt-5".to_string(), - reasoning_effort: ReasoningEffortConfig::default(), - status: AgentStatus::Running, - }), - ); - let end_events = ep.collect_thread_events(&end); - assert_eq!( - end_events, - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::CollabToolCall(CollabToolCallItem { - tool: CollabTool::SpawnAgent, - sender_thread_id: sender_thread_id.to_string(), - receiver_thread_ids: vec![new_thread_id.to_string()], - prompt: Some(prompt), - agents_states: [( - new_thread_id.to_string(), - CollabAgentState { - status: CollabAgentStatus::Running, - message: None, - }, - )] - .into_iter() - .collect(), - status: CollabToolCallStatus::Completed, - }), - }, - })] - ); -} - -#[test] -fn collab_wait_end_without_begin_synthesizes_failed_item() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let sender_thread_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8").unwrap(); - let running_thread_id = ThreadId::from_string("3f76d2a0-943e-4f43-8a38-b289c9c6c3d1").unwrap(); - let failed_thread_id = ThreadId::from_string("c1dfd96e-1f0c-4f26-9b4f-1aa02c2d3c4d").unwrap(); - let mut receiver_thread_ids = vec![running_thread_id.to_string(), failed_thread_id.to_string()]; - receiver_thread_ids.sort(); - let mut statuses = std::collections::HashMap::new(); - statuses.insert( - running_thread_id, - AgentStatus::Completed(Some("done".to_string())), - ); - statuses.insert(failed_thread_id, AgentStatus::Errored("boom".to_string())); - - let end = event( - "c3", - EventMsg::CollabWaitingEnd(CollabWaitingEndEvent { - sender_thread_id, - call_id: "call-11".to_string(), - agent_statuses: Vec::new(), - statuses: statuses.clone(), - }), - ); - let events = ep.collect_thread_events(&end); - assert_eq!( - events, - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::CollabToolCall(CollabToolCallItem { - tool: CollabTool::Wait, - sender_thread_id: sender_thread_id.to_string(), - receiver_thread_ids, - prompt: None, - agents_states: [ - ( - running_thread_id.to_string(), - CollabAgentState { - status: CollabAgentStatus::Completed, - message: Some("done".to_string()), - }, - ), - ( - failed_thread_id.to_string(), - CollabAgentState { - status: CollabAgentStatus::Errored, - message: Some("boom".to_string()), - }, - ), - ] - .into_iter() - .collect(), - status: CollabToolCallStatus::Failed, - }), - }, - })] - ); -} - -#[test] -fn plan_update_after_complete_starts_new_todo_list_with_new_id() { - let mut ep = EventProcessorWithJsonOutput::new(None); - - // First turn: start + complete - let start = event( - "t1", - EventMsg::PlanUpdate(UpdatePlanArgs { - explanation: None, - plan: vec![PlanItemArg { - step: "only".to_string(), - status: StepStatus::Pending, - }], - }), - ); - let _ = ep.collect_thread_events(&start); - let complete = event( - "t2", - EventMsg::TurnComplete(codex_protocol::protocol::TurnCompleteEvent { + thread_id: "thread-parent".to_string(), turn_id: "turn-1".to_string(), - last_agent_message: None, - }), - ); - let _ = ep.collect_thread_events(&complete); - - // Second turn: a new todo list should have a new id - let start_again = event( - "t3", - EventMsg::PlanUpdate(UpdatePlanArgs { - explanation: None, - plan: vec![PlanItemArg { - step: "again".to_string(), - status: StepStatus::Pending, - }], - }), - ); - let out = ep.collect_thread_events(&start_again); - - match &out[0] { - ThreadEvent::ItemStarted(ItemStartedEvent { item }) => { - assert_eq!(&item.id, "item_1"); - } - other => panic!("unexpected event: {other:?}"), - } -} - -#[test] -fn agent_reasoning_produces_item_completed_reasoning() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let ev = event( - "e1", - EventMsg::AgentReasoning(AgentReasoningEvent { - text: "thinking...".to_string(), - }), - ); - let out = ep.collect_thread_events(&ev); - assert_eq!( - out, - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::Reasoning(ReasoningItem { - text: "thinking...".to_string(), - }), + })); + let completed = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::CollabAgentToolCall { + id: "collab-1".to_string(), + tool: CollabAgentTool::SpawnAgent, + status: ApiCollabAgentToolCallStatus::Completed, + sender_thread_id: "thread-parent".to_string(), + receiver_thread_ids: vec!["thread-child".to_string()], + prompt: Some("draft a plan".to_string()), + model: Some("gpt-5".to_string()), + reasoning_effort: None, + agents_states: std::collections::HashMap::from([( + "thread-child".to_string(), + ApiCollabAgentState { + status: ApiCollabAgentStatus::Running, + message: None, + }, + )]), }, - })] - ); -} - -#[test] -fn agent_message_produces_item_completed_agent_message() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let ev = event( - "e1", - EventMsg::AgentMessage(AgentMessageEvent { - message: "hello".to_string(), - phase: None, - memory_citation: None, - }), - ); - let out = ep.collect_thread_events(&ev); - assert_eq!( - out, - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::AgentMessage(AgentMessageItem { - text: "hello".to_string(), - }), - }, - })] - ); -} - -#[test] -fn error_event_produces_error() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let out = ep.collect_thread_events(&event( - "e1", - EventMsg::Error(codex_protocol::protocol::ErrorEvent { - message: "boom".to_string(), - codex_error_info: Some(CodexErrorInfo::Other), - }), + thread_id: "thread-parent".to_string(), + turn_id: "turn-1".to_string(), + }, )); + assert_eq!( - out, - vec![ThreadEvent::Error(ThreadErrorEvent { - message: "boom".to_string(), - })] + started, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemStarted(ItemStartedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::CollabToolCall(CollabToolCallItem { + tool: CollabTool::SpawnAgent, + sender_thread_id: "thread-parent".to_string(), + receiver_thread_ids: Vec::new(), + prompt: Some("draft a plan".to_string()), + agents_states: std::collections::HashMap::new(), + status: CollabToolCallStatus::InProgress, + },), + }, + })], + status: CodexStatus::Running, + } + ); + assert_eq!( + completed, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::CollabToolCall(CollabToolCallItem { + tool: CollabTool::SpawnAgent, + sender_thread_id: "thread-parent".to_string(), + receiver_thread_ids: vec!["thread-child".to_string()], + prompt: Some("draft a plan".to_string()), + agents_states: std::collections::HashMap::from([( + "thread-child".to_string(), + CollabAgentState { + status: CollabAgentStatus::Running, + message: None, + }, + )]), + status: CollabToolCallStatus::Completed, + },), + }, + })], + status: CodexStatus::Running, + } + ); +} + +#[test] +fn file_change_completion_maps_change_kinds() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::FileChange { + id: "patch-1".to_string(), + changes: vec![ + ApiFileUpdateChange { + path: "a/added.txt".to_string(), + kind: ApiPatchChangeKind::Add, + diff: String::new(), + }, + ApiFileUpdateChange { + path: "b/deleted.txt".to_string(), + kind: ApiPatchChangeKind::Delete, + diff: String::new(), + }, + ApiFileUpdateChange { + path: "c/modified.txt".to_string(), + kind: ApiPatchChangeKind::Update { move_path: None }, + diff: "@@ -1 +1 @@".to_string(), + }, + ], + status: ApiPatchApplyStatus::Completed, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!( + collected, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::FileChange(FileChangeItem { + changes: vec![ + ExecFileUpdateChange { + path: "a/added.txt".to_string(), + kind: PatchChangeKind::Add, + }, + ExecFileUpdateChange { + path: "b/deleted.txt".to_string(), + kind: PatchChangeKind::Delete, + }, + ExecFileUpdateChange { + path: "c/modified.txt".to_string(), + kind: PatchChangeKind::Update, + }, + ], + status: PatchApplyStatus::Completed, + }), + }, + })], + status: CodexStatus::Running, + } + ); +} + +#[test] +fn file_change_declined_maps_to_failed_status() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::FileChange { + id: "patch-2".to_string(), + changes: vec![ApiFileUpdateChange { + path: "file.txt".to_string(), + kind: ApiPatchChangeKind::Update { move_path: None }, + diff: "@@ -1 +1 @@".to_string(), + }], + status: ApiPatchApplyStatus::Declined, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!( + collected, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::FileChange(FileChangeItem { + changes: vec![ExecFileUpdateChange { + path: "file.txt".to_string(), + kind: PatchChangeKind::Update, + }], + status: PatchApplyStatus::Failed, + }), + }, + })], + status: CodexStatus::Running, + } + ); +} + +#[test] +fn agent_message_item_updates_final_message() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "hello".to_string(), + phase: None, + memory_citation: None, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!( + collected, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::AgentMessage(AgentMessageItem { + text: "hello".to_string(), + }), + }, + })], + status: CodexStatus::Running, + } + ); + assert_eq!(processor.final_message(), Some("hello")); +} + +#[test] +fn agent_message_item_started_is_ignored() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let collected = + processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification { + item: ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "hello".to_string(), + phase: None, + memory_citation: None, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + })); + + assert_eq!( + collected, + CollectedThreadEvents { + events: Vec::new(), + status: CodexStatus::Running, + } + ); +} + +#[test] +fn reasoning_item_completed_uses_synthetic_id() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::Reasoning { + id: "rs-1".to_string(), + summary: vec!["thinking...".to_string()], + content: vec!["raw".to_string()], + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!( + collected, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::Reasoning(ReasoningItem { + text: "thinking...".to_string(), + }), + }, + })], + status: CodexStatus::Running, + } ); } #[test] fn warning_event_produces_error_item() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let out = ep.collect_thread_events(&event( - "e1", - EventMsg::Warning(WarningEvent { - message: "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start a new conversation when possible to keep conversations small and targeted.".to_string(), - }), + let mut processor = EventProcessorWithJsonOutput::new(None); + + let collected = processor.collect_warning( + "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start a new conversation when possible to keep conversations small and targeted.".to_string(), + ); + + assert_eq!( + collected, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::Error(ErrorItem { + message: "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start a new conversation when possible to keep conversations small and targeted.".to_string(), + }), + }, + })], + status: CodexStatus::Running, + } + ); +} + +#[test] +fn plan_update_emits_started_then_updated_then_completed() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let started = processor.collect_thread_events(ServerNotification::TurnPlanUpdated( + TurnPlanUpdatedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + explanation: None, + plan: vec![ + TurnPlanStep { + step: "step one".to_string(), + status: TurnPlanStepStatus::Pending, + }, + TurnPlanStep { + step: "step two".to_string(), + status: TurnPlanStepStatus::InProgress, + }, + ], + }, )); assert_eq!( - out, - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::Error(ErrorItem { - message: "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start a new conversation when possible to keep conversations small and targeted.".to_string(), - }), - }, - })] + started, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemStarted(ItemStartedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::TodoList(TodoListItem { + items: vec![ + TodoItem { + text: "step one".to_string(), + completed: false, + }, + TodoItem { + text: "step two".to_string(), + completed: false, + }, + ], + }), + }, + })], + status: CodexStatus::Running, + } ); -} -#[test] -fn stream_error_event_produces_error() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let out = ep.collect_thread_events(&event( - "e1", - EventMsg::StreamError(codex_protocol::protocol::StreamErrorEvent { - message: "retrying".to_string(), - codex_error_info: Some(CodexErrorInfo::Other), - additional_details: None, - }), + let updated = processor.collect_thread_events(ServerNotification::TurnPlanUpdated( + TurnPlanUpdatedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + explanation: None, + plan: vec![ + TurnPlanStep { + step: "step one".to_string(), + status: TurnPlanStepStatus::Completed, + }, + TurnPlanStep { + step: "step two".to_string(), + status: TurnPlanStepStatus::InProgress, + }, + ], + }, )); assert_eq!( - out, - vec![ThreadEvent::Error(ThreadErrorEvent { - message: "retrying".to_string(), - })] - ); -} - -#[test] -fn error_followed_by_task_complete_produces_turn_failed() { - let mut ep = EventProcessorWithJsonOutput::new(None); - - let error_event = event( - "e1", - EventMsg::Error(ErrorEvent { - message: "boom".to_string(), - codex_error_info: Some(CodexErrorInfo::Other), - }), - ); - assert_eq!( - ep.collect_thread_events(&error_event), - vec![ThreadEvent::Error(ThreadErrorEvent { - message: "boom".to_string(), - })] - ); - - let complete_event = event( - "e2", - EventMsg::TurnComplete(codex_protocol::protocol::TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: None, - }), - ); - assert_eq!( - ep.collect_thread_events(&complete_event), - vec![ThreadEvent::TurnFailed(TurnFailedEvent { - error: ThreadErrorEvent { - message: "boom".to_string(), - }, - })] - ); -} - -#[test] -fn exec_command_end_success_produces_completed_command_item() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let command = vec!["bash".to_string(), "-lc".to_string(), "echo hi".to_string()]; - let cwd = std::env::current_dir().unwrap(); - let parsed_cmd = Vec::new(); - - // Begin -> no output - let begin = event( - "c1", - EventMsg::ExecCommandBegin(ExecCommandBeginEvent { - call_id: "1".to_string(), - process_id: None, - turn_id: "turn-1".to_string(), - command: command.clone(), - cwd: cwd.clone(), - parsed_cmd: parsed_cmd.clone(), - source: ExecCommandSource::Agent, - interaction_input: None, - }), - ); - let out_begin = ep.collect_thread_events(&begin); - assert_eq!( - out_begin, - vec![ThreadEvent::ItemStarted(ItemStartedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::CommandExecution(CommandExecutionItem { - command: "bash -lc 'echo hi'".to_string(), - aggregated_output: String::new(), - exit_code: None, - status: CommandExecutionStatus::InProgress, - }), - }, - })] - ); - - // End (success) -> item.completed (item_0) - let end_ok = event( - "c2", - EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id: "1".to_string(), - process_id: None, - turn_id: "turn-1".to_string(), - command, - cwd, - parsed_cmd, - source: ExecCommandSource::Agent, - interaction_input: None, - stdout: String::new(), - stderr: String::new(), - aggregated_output: "hi\n".to_string(), - exit_code: 0, - duration: Duration::from_millis(5), - formatted_output: String::new(), - status: CoreExecCommandStatus::Completed, - }), - ); - let out_ok = ep.collect_thread_events(&end_ok); - assert_eq!( - out_ok, - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::CommandExecution(CommandExecutionItem { - command: "bash -lc 'echo hi'".to_string(), - aggregated_output: "hi\n".to_string(), - exit_code: Some(0), - status: CommandExecutionStatus::Completed, - }), - }, - })] - ); -} - -#[test] -fn command_execution_output_delta_updates_item_progress() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let command = vec![ - "bash".to_string(), - "-lc".to_string(), - "echo delta".to_string(), - ]; - let cwd = std::env::current_dir().unwrap(); - let parsed_cmd = Vec::new(); - - let begin = event( - "d1", - EventMsg::ExecCommandBegin(ExecCommandBeginEvent { - call_id: "delta-1".to_string(), - process_id: Some("42".to_string()), - turn_id: "turn-1".to_string(), - command: command.clone(), - cwd: cwd.clone(), - parsed_cmd: parsed_cmd.clone(), - source: ExecCommandSource::Agent, - interaction_input: None, - }), - ); - let out_begin = ep.collect_thread_events(&begin); - assert_eq!( - out_begin, - vec![ThreadEvent::ItemStarted(ItemStartedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::CommandExecution(CommandExecutionItem { - command: "bash -lc 'echo delta'".to_string(), - aggregated_output: String::new(), - exit_code: None, - status: CommandExecutionStatus::InProgress, - }), - }, - })] - ); - - let delta = event( - "d2", - EventMsg::ExecCommandOutputDelta(ExecCommandOutputDeltaEvent { - call_id: "delta-1".to_string(), - stream: ExecOutputStream::Stdout, - chunk: b"partial output\n".to_vec(), - }), - ); - let out_delta = ep.collect_thread_events(&delta); - assert_eq!(out_delta, Vec::::new()); - - let end = event( - "d3", - EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id: "delta-1".to_string(), - process_id: Some("42".to_string()), - turn_id: "turn-1".to_string(), - command, - cwd, - parsed_cmd, - source: ExecCommandSource::Agent, - interaction_input: None, - stdout: String::new(), - stderr: String::new(), - aggregated_output: String::new(), - exit_code: 0, - duration: Duration::from_millis(3), - formatted_output: String::new(), - status: CoreExecCommandStatus::Completed, - }), - ); - let out_end = ep.collect_thread_events(&end); - assert_eq!( - out_end, - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::CommandExecution(CommandExecutionItem { - command: "bash -lc 'echo delta'".to_string(), - aggregated_output: String::new(), - exit_code: Some(0), - status: CommandExecutionStatus::Completed, - }), - }, - })] - ); -} - -#[test] -fn exec_command_end_failure_produces_failed_command_item() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let command = vec!["sh".to_string(), "-c".to_string(), "exit 1".to_string()]; - let cwd = std::env::current_dir().unwrap(); - let parsed_cmd = Vec::new(); - - // Begin -> no output - let begin = event( - "c1", - EventMsg::ExecCommandBegin(ExecCommandBeginEvent { - call_id: "2".to_string(), - process_id: None, - turn_id: "turn-1".to_string(), - command: command.clone(), - cwd: cwd.clone(), - parsed_cmd: parsed_cmd.clone(), - source: ExecCommandSource::Agent, - interaction_input: None, - }), - ); - assert_eq!( - ep.collect_thread_events(&begin), - vec![ThreadEvent::ItemStarted(ItemStartedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::CommandExecution(CommandExecutionItem { - command: "sh -c 'exit 1'".to_string(), - aggregated_output: String::new(), - exit_code: None, - status: CommandExecutionStatus::InProgress, - }), - }, - })] - ); - - // End (failure) -> item.completed (item_0) - let end_fail = event( - "c2", - EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id: "2".to_string(), - process_id: None, - turn_id: "turn-1".to_string(), - command, - cwd, - parsed_cmd, - source: ExecCommandSource::Agent, - interaction_input: None, - stdout: String::new(), - stderr: String::new(), - aggregated_output: String::new(), - exit_code: 1, - duration: Duration::from_millis(2), - formatted_output: String::new(), - status: CoreExecCommandStatus::Failed, - }), - ); - let out_fail = ep.collect_thread_events(&end_fail); - assert_eq!( - out_fail, - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::CommandExecution(CommandExecutionItem { - command: "sh -c 'exit 1'".to_string(), - aggregated_output: String::new(), - exit_code: Some(1), - status: CommandExecutionStatus::Failed, - }), - }, - })] - ); -} - -#[test] -fn exec_command_end_without_begin_is_ignored() { - let mut ep = EventProcessorWithJsonOutput::new(None); - - // End event arrives without a prior Begin; should produce no thread events. - let end_only = event( - "c1", - EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id: "no-begin".to_string(), - process_id: None, - turn_id: "turn-1".to_string(), - command: Vec::new(), - cwd: PathBuf::from("."), - parsed_cmd: Vec::new(), - source: ExecCommandSource::Agent, - interaction_input: None, - stdout: String::new(), - stderr: String::new(), - aggregated_output: String::new(), - exit_code: 0, - duration: Duration::from_millis(1), - formatted_output: String::new(), - status: CoreExecCommandStatus::Completed, - }), - ); - let out = ep.collect_thread_events(&end_only); - assert!(out.is_empty()); -} - -#[test] -fn patch_apply_success_produces_item_completed_patchapply() { - let mut ep = EventProcessorWithJsonOutput::new(None); - - // Prepare a patch with multiple kinds of changes - let mut changes = std::collections::HashMap::new(); - changes.insert( - PathBuf::from("a/added.txt"), - FileChange::Add { - content: "+hello".to_string(), - }, - ); - changes.insert( - PathBuf::from("b/deleted.txt"), - FileChange::Delete { - content: "-goodbye".to_string(), - }, - ); - changes.insert( - PathBuf::from("c/modified.txt"), - FileChange::Update { - unified_diff: "--- c/modified.txt\n+++ c/modified.txt\n@@\n-old\n+new\n".to_string(), - move_path: Some(PathBuf::from("c/renamed.txt")), - }, - ); - - // Begin -> no output - let begin = event( - "p1", - EventMsg::PatchApplyBegin(PatchApplyBeginEvent { - call_id: "call-1".to_string(), - turn_id: "turn-1".to_string(), - auto_approved: true, - changes: changes.clone(), - }), - ); - let out_begin = ep.collect_thread_events(&begin); - assert!(out_begin.is_empty()); - - // End (success) -> item.completed (item_0) - let end = event( - "p2", - EventMsg::PatchApplyEnd(PatchApplyEndEvent { - call_id: "call-1".to_string(), - turn_id: "turn-1".to_string(), - stdout: "applied 3 changes".to_string(), - stderr: String::new(), - success: true, - changes: changes.clone(), - status: CorePatchApplyStatus::Completed, - }), - ); - let out_end = ep.collect_thread_events(&end); - assert_eq!(out_end.len(), 1); - - // Validate structure without relying on HashMap iteration order - match &out_end[0] { - ThreadEvent::ItemCompleted(ItemCompletedEvent { item }) => { - assert_eq!(&item.id, "item_0"); - match &item.details { - ThreadItemDetails::FileChange(file_update) => { - assert_eq!(file_update.status, PatchApplyStatus::Completed); - - let mut actual: Vec<(String, PatchChangeKind)> = file_update - .changes - .iter() - .map(|c| (c.path.clone(), c.kind.clone())) - .collect(); - actual.sort_by(|a, b| a.0.cmp(&b.0)); - - let mut expected = vec![ - ("a/added.txt".to_string(), PatchChangeKind::Add), - ("b/deleted.txt".to_string(), PatchChangeKind::Delete), - ("c/modified.txt".to_string(), PatchChangeKind::Update), - ]; - expected.sort_by(|a, b| a.0.cmp(&b.0)); - - assert_eq!(actual, expected); - } - other => panic!("unexpected details: {other:?}"), - } + updated, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemUpdated(ItemUpdatedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::TodoList(TodoListItem { + items: vec![ + TodoItem { + text: "step one".to_string(), + completed: true, + }, + TodoItem { + text: "step two".to_string(), + completed: false, + }, + ], + }), + }, + })], + status: CodexStatus::Running, } - other => panic!("unexpected event: {other:?}"), - } -} - -#[test] -fn patch_apply_failure_produces_item_completed_patchapply_failed() { - let mut ep = EventProcessorWithJsonOutput::new(None); - - let mut changes = std::collections::HashMap::new(); - changes.insert( - PathBuf::from("file.txt"), - FileChange::Update { - unified_diff: "--- file.txt\n+++ file.txt\n@@\n-old\n+new\n".to_string(), - move_path: None, - }, ); - // Begin -> no output - let begin = event( - "p1", - EventMsg::PatchApplyBegin(PatchApplyBeginEvent { - call_id: "call-2".to_string(), - turn_id: "turn-2".to_string(), - auto_approved: false, - changes: changes.clone(), - }), - ); - assert!(ep.collect_thread_events(&begin).is_empty()); - - // End (failure) -> item.completed (item_0) with Failed status - let end = event( - "p2", - EventMsg::PatchApplyEnd(PatchApplyEndEvent { - call_id: "call-2".to_string(), - turn_id: "turn-2".to_string(), - stdout: String::new(), - stderr: "failed to apply".to_string(), - success: false, - changes: changes.clone(), - status: CorePatchApplyStatus::Failed, - }), - ); - let out_end = ep.collect_thread_events(&end); - assert_eq!(out_end.len(), 1); - - match &out_end[0] { - ThreadEvent::ItemCompleted(ItemCompletedEvent { item }) => { - assert_eq!(&item.id, "item_0"); - match &item.details { - ThreadItemDetails::FileChange(file_update) => { - assert_eq!(file_update.status, PatchApplyStatus::Failed); - assert_eq!(file_update.changes.len(), 1); - assert_eq!(file_update.changes[0].path, "file.txt".to_string()); - assert_eq!(file_update.changes[0].kind, PatchChangeKind::Update); - } - other => panic!("unexpected details: {other:?}"), - } - } - other => panic!("unexpected event: {other:?}"), - } -} - -#[test] -fn task_complete_produces_turn_completed_with_usage() { - let mut ep = EventProcessorWithJsonOutput::new(None); - - // First, feed a TokenCount event with known totals. - let usage = codex_protocol::protocol::TokenUsage { - input_tokens: 1200, - cached_input_tokens: 200, - output_tokens: 345, - reasoning_output_tokens: 0, - total_tokens: 0, - }; - let info = codex_protocol::protocol::TokenUsageInfo { - total_token_usage: usage.clone(), - last_token_usage: usage, - model_context_window: None, - }; - let token_count_event = event( - "e1", - EventMsg::TokenCount(codex_protocol::protocol::TokenCountEvent { - info: Some(info), - rate_limits: None, - }), - ); - assert!(ep.collect_thread_events(&token_count_event).is_empty()); - - // Then TurnComplete should produce turn.completed with the captured usage. - let complete_event = event( - "e2", - EventMsg::TurnComplete(codex_protocol::protocol::TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: Some("done".to_string()), - }), - ); - let out = ep.collect_thread_events(&complete_event); - assert_eq!( - out, - vec![ThreadEvent::TurnCompleted(TurnCompletedEvent { - usage: Usage { - input_tokens: 1200, - cached_input_tokens: 200, - output_tokens: 345, + let completed = processor.collect_thread_events(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::Completed, + error: None, }, - })] + }, + )); + assert_eq!( + completed, + CollectedThreadEvents { + events: vec![ + ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::TodoList(TodoListItem { + items: vec![ + TodoItem { + text: "step one".to_string(), + completed: true, + }, + TodoItem { + text: "step two".to_string(), + completed: false, + }, + ], + }), + }, + }), + ThreadEvent::TurnCompleted(TurnCompletedEvent { + usage: Usage::default(), + }), + ], + status: CodexStatus::InitiateShutdown, + } + ); +} + +#[test] +fn plan_update_after_completion_starts_new_todo_list_with_new_id() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let _ = processor.collect_thread_events(ServerNotification::TurnPlanUpdated( + TurnPlanUpdatedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + explanation: None, + plan: vec![TurnPlanStep { + step: "only".to_string(), + status: TurnPlanStepStatus::Pending, + }], + }, + )); + let _ = processor.collect_thread_events(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::Completed, + error: None, + }, + }, + )); + + let restarted = processor.collect_thread_events(ServerNotification::TurnPlanUpdated( + TurnPlanUpdatedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-2".to_string(), + explanation: None, + plan: vec![TurnPlanStep { + step: "again".to_string(), + status: TurnPlanStepStatus::Pending, + }], + }, + )); + + assert_eq!( + restarted, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemStarted(ItemStartedEvent { + item: ExecThreadItem { + id: "item_1".to_string(), + details: ThreadItemDetails::TodoList(TodoListItem { + items: vec![TodoItem { + text: "again".to_string(), + completed: false, + }], + }), + }, + })], + status: CodexStatus::Running, + } + ); +} + +#[test] +fn token_usage_update_is_emitted_on_turn_completion() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let usage_update = + processor.collect_thread_events(ServerNotification::ThreadTokenUsageUpdated( + codex_app_server_protocol::ThreadTokenUsageUpdatedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + token_usage: ThreadTokenUsage { + total: TokenUsageBreakdown { + total_tokens: 42, + input_tokens: 10, + cached_input_tokens: 3, + output_tokens: 29, + reasoning_output_tokens: 7, + }, + last: TokenUsageBreakdown { + total_tokens: 42, + input_tokens: 10, + cached_input_tokens: 3, + output_tokens: 29, + reasoning_output_tokens: 7, + }, + model_context_window: Some(128_000), + }, + }, + )); + assert_eq!( + usage_update, + CollectedThreadEvents { + events: Vec::new(), + status: CodexStatus::Running, + } + ); + + let completed = processor.collect_thread_events(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::Completed, + error: None, + }, + }, + )); + assert_eq!( + completed, + CollectedThreadEvents { + events: vec![ThreadEvent::TurnCompleted(TurnCompletedEvent { + usage: Usage { + input_tokens: 10, + cached_input_tokens: 3, + output_tokens: 29, + }, + })], + status: CodexStatus::InitiateShutdown, + } + ); +} + +#[test] +fn turn_completion_recovers_final_message_from_turn_items() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let completed = processor.collect_thread_events(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: vec![ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "final answer".to_string(), + phase: None, + memory_citation: None, + }], + status: TurnStatus::Completed, + error: None, + }, + }, + )); + + assert_eq!( + completed, + CollectedThreadEvents { + events: vec![ThreadEvent::TurnCompleted(TurnCompletedEvent { + usage: Usage::default(), + })], + status: CodexStatus::InitiateShutdown, + } + ); + assert_eq!(processor.final_message(), Some("final answer")); +} + +#[test] +fn turn_completion_reconciles_started_items_from_turn_items() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let started = + processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification { + item: ThreadItem::CommandExecution { + id: "cmd-1".to_string(), + command: "ls".to_string(), + cwd: PathBuf::from("/tmp/project"), + process_id: Some("123".to_string()), + source: CommandExecutionSource::UserShell, + status: ApiCommandExecutionStatus::InProgress, + command_actions: Vec::::new(), + aggregated_output: None, + exit_code: None, + duration_ms: None, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + })); + assert_eq!( + started, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemStarted(ItemStartedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::CommandExecution(CommandExecutionItem { + command: "ls".to_string(), + aggregated_output: String::new(), + exit_code: None, + status: CommandExecutionStatus::InProgress, + }), + }, + })], + status: CodexStatus::Running, + } + ); + + let completed = processor.collect_thread_events(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: vec![ThreadItem::CommandExecution { + id: "cmd-1".to_string(), + command: "ls".to_string(), + cwd: PathBuf::from("/tmp/project"), + process_id: Some("123".to_string()), + source: CommandExecutionSource::UserShell, + status: ApiCommandExecutionStatus::Completed, + command_actions: Vec::::new(), + aggregated_output: Some("a.txt\n".to_string()), + exit_code: Some(0), + duration_ms: Some(3), + }], + status: TurnStatus::Completed, + error: None, + }, + }, + )); + + assert_eq!( + completed, + CollectedThreadEvents { + events: vec![ + ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::CommandExecution(CommandExecutionItem { + command: "ls".to_string(), + aggregated_output: "a.txt\n".to_string(), + exit_code: Some(0), + status: CommandExecutionStatus::Completed, + }), + }, + }), + ThreadEvent::TurnCompleted(TurnCompletedEvent { + usage: Usage::default(), + }), + ], + status: CodexStatus::InitiateShutdown, + } + ); +} + +#[test] +fn turn_completion_overwrites_stale_final_message_from_turn_items() { + let mut processor = EventProcessorWithJsonOutput::new(None); + let _ = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::AgentMessage { + id: "msg-stale".to_string(), + text: "stale answer".to_string(), + phase: None, + memory_citation: None, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + let completed = processor.collect_thread_events(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: vec![ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "final answer".to_string(), + phase: None, + memory_citation: None, + }], + status: TurnStatus::Completed, + error: None, + }, + }, + )); + + assert_eq!( + completed, + CollectedThreadEvents { + events: vec![ThreadEvent::TurnCompleted(TurnCompletedEvent { + usage: Usage::default(), + })], + status: CodexStatus::InitiateShutdown, + } + ); + assert_eq!(processor.final_message(), Some("final answer")); +} + +#[test] +fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() { + let mut processor = EventProcessorWithJsonOutput::new(None); + let _ = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::AgentMessage { + id: "msg-streamed".to_string(), + text: "streamed answer".to_string(), + phase: None, + memory_citation: None, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + let completed = processor.collect_thread_events(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::Completed, + error: None, + }, + }, + )); + + assert_eq!( + completed, + CollectedThreadEvents { + events: vec![ThreadEvent::TurnCompleted(TurnCompletedEvent { + usage: Usage::default(), + })], + status: CodexStatus::InitiateShutdown, + } + ); + assert_eq!(processor.final_message(), Some("streamed answer")); +} + +#[test] +fn failed_turn_clears_stale_final_message() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "partial answer".to_string(), + phase: None, + memory_citation: None, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!(collected.status, CodexStatus::Running); + assert_eq!(processor.final_message(), Some("partial answer")); + + let collected = processor.collect_thread_events(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::Failed, + error: Some(TurnError { + message: "turn failed".to_string(), + additional_details: None, + codex_error_info: None, + }), + }, + }, + )); + + assert_eq!(collected.status, CodexStatus::InitiateShutdown); + assert_eq!(processor.final_message(), None); +} + +#[test] +fn turn_completion_falls_back_to_final_plan_text() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let completed = processor.collect_thread_events(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: vec![ThreadItem::Plan { + id: "plan-1".to_string(), + text: "ship the typed adapter".to_string(), + }], + status: TurnStatus::Completed, + error: None, + }, + }, + )); + + assert_eq!( + completed, + CollectedThreadEvents { + events: vec![ThreadEvent::TurnCompleted(TurnCompletedEvent { + usage: Usage::default(), + })], + status: CodexStatus::InitiateShutdown, + } + ); + assert_eq!(processor.final_message(), Some("ship the typed adapter")); +} + +#[test] +fn turn_failure_prefers_structured_error_message() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let error = processor.collect_thread_events(ServerNotification::Error(ErrorNotification { + error: TurnError { + message: "backend failed".to_string(), + codex_error_info: None, + additional_details: Some("request id abc".to_string()), + }, + will_retry: false, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + })); + assert_eq!( + error, + CollectedThreadEvents { + events: vec![ThreadEvent::Error(ThreadErrorEvent { + message: "backend failed (request id abc)".to_string(), + })], + status: CodexStatus::Running, + } + ); + + let failed = processor.collect_thread_events(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::Failed, + error: None, + }, + }, + )); + assert_eq!( + failed, + CollectedThreadEvents { + events: vec![ThreadEvent::TurnFailed(TurnFailedEvent { + error: ThreadErrorEvent { + message: "backend failed (request id abc)".to_string(), + }, + })], + status: CodexStatus::InitiateShutdown, + } + ); +} + +#[test] +fn model_reroute_surfaces_as_error_item() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let collected = processor.collect_thread_events(ServerNotification::ModelRerouted( + codex_app_server_protocol::ModelReroutedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + from_model: "gpt-5".to_string(), + to_model: "gpt-5-mini".to_string(), + reason: codex_app_server_protocol::ModelRerouteReason::HighRiskCyberActivity, + }, + )); + + assert_eq!(collected.status, CodexStatus::Running); + assert_eq!(collected.events.len(), 1); + let ThreadEvent::ItemCompleted(ItemCompletedEvent { item }) = &collected.events[0] else { + panic!("expected ItemCompleted"); + }; + assert_eq!(item.id, "item_0"); + assert_eq!( + item.details, + ThreadItemDetails::Error(ErrorItem { + message: "model rerouted: gpt-5 -> gpt-5-mini (HighRiskCyberActivity)".to_string(), + }) ); } diff --git a/codex-rs/tui/tests/suite/model_availability_nux.rs b/codex-rs/tui/tests/suite/model_availability_nux.rs index f1bee3160..7ba0ec4b2 100644 --- a/codex-rs/tui/tests/suite/model_availability_nux.rs +++ b/codex-rs/tui/tests/suite/model_availability_nux.rs @@ -178,10 +178,11 @@ trust_level = "trusted" anyhow::bail!("timed out waiting for codex resume to exit"); } }; + let output_text = String::from_utf8_lossy(&output); + let interrupted_startup = exit_code == 1 && output_text.trim() == "^C"; anyhow::ensure!( - exit_code == 0 || exit_code == 130, - "unexpected exit code from codex resume: {exit_code}; output: {}", - String::from_utf8_lossy(&output) + exit_code == 0 || exit_code == 130 || interrupted_startup, + "unexpected exit code from codex resume: {exit_code}; output: {output_text}", ); let config_contents = std::fs::read_to_string(codex_home.path().join("config.toml"))?; diff --git a/codex-rs/tui_app_server/src/app/app_server_adapter.rs b/codex-rs/tui_app_server/src/app/app_server_adapter.rs index 952a9bf7b..20f13589c 100644 --- a/codex-rs/tui_app_server/src/app/app_server_adapter.rs +++ b/codex-rs/tui_app_server/src/app/app_server_adapter.rs @@ -122,9 +122,6 @@ impl App { self.handle_server_notification_event(app_server_client, notification) .await; } - AppServerEvent::LegacyNotification(_) => { - tracing::debug!("ignoring legacy app-server notification in tui_app_server"); - } AppServerEvent::ServerRequest(request) => { self.handle_server_request_event(app_server_client, request) .await; diff --git a/codex-rs/tui_app_server/src/onboarding/onboarding_screen.rs b/codex-rs/tui_app_server/src/onboarding/onboarding_screen.rs index 61b00d6be..b6afd2cb9 100644 --- a/codex-rs/tui_app_server/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui_app_server/src/onboarding/onboarding_screen.rs @@ -504,7 +504,6 @@ pub(crate) async fn run_onboarding_app( return Err(color_eyre::eyre::eyre!(message)); } AppServerEvent::Lagged { .. } - | AppServerEvent::LegacyNotification(_) | AppServerEvent::ServerRequest(_) => {} } } diff --git a/codex-rs/tui_app_server/tests/suite/model_availability_nux.rs b/codex-rs/tui_app_server/tests/suite/model_availability_nux.rs index f1bee3160..7ba0ec4b2 100644 --- a/codex-rs/tui_app_server/tests/suite/model_availability_nux.rs +++ b/codex-rs/tui_app_server/tests/suite/model_availability_nux.rs @@ -178,10 +178,11 @@ trust_level = "trusted" anyhow::bail!("timed out waiting for codex resume to exit"); } }; + let output_text = String::from_utf8_lossy(&output); + let interrupted_startup = exit_code == 1 && output_text.trim() == "^C"; anyhow::ensure!( - exit_code == 0 || exit_code == 130, - "unexpected exit code from codex resume: {exit_code}; output: {}", - String::from_utf8_lossy(&output) + exit_code == 0 || exit_code == 130 || interrupted_startup, + "unexpected exit code from codex resume: {exit_code}; output: {output_text}", ); let config_contents = std::fs::read_to_string(codex_home.path().join("config.toml"))?;