diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index acf9c77a10..7ea9552c41 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -28,6 +28,9 @@ use std::time::Duration; pub use codex_app_server::in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY; pub use codex_app_server::in_process::InProcessServerEvent; use codex_app_server::in_process::InProcessStartArgs; +use codex_app_server_protocol::AuthMode; +use codex_app_server_protocol::ChatgptAuthTokensRefreshParams; +use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientNotification; use codex_app_server_protocol::ClientRequest; @@ -41,13 +44,11 @@ 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::auth::AuthCredentialsStoreMode; +use codex_core::auth::load_auth_dot_json; 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; @@ -159,16 +160,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. @@ -199,33 +190,14 @@ pub struct InProcessClientStartArgs { pub opt_out_notification_methods: Vec, /// Queue capacity for command/event channels (clamped to at least 1). pub channel_capacity: usize, + /// Whether the client worker should satisfy local ChatGPT auth refresh + /// requests instead of surfacing them to the embedder. + pub auto_handle_chatgpt_auth_refresh: bool, + /// Whether legacy `codex/event/*` notifications should be forwarded. + pub allow_legacy_notifications: bool, } 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 +219,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,14 +227,15 @@ 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()), + auth_manager: None, + thread_manager: None, feedback: self.feedback, config_warnings: self.config_warnings, session_source: self.session_source, enable_codex_api_key_env: self.enable_codex_api_key_env, initialize, channel_capacity: self.channel_capacity, + allow_legacy_notifications: self.allow_legacy_notifications, } } } @@ -310,8 +283,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)] @@ -330,6 +301,59 @@ pub enum AppServerClient { Remote(RemoteAppServerClient), } +#[derive(Clone)] +struct ChatgptAuthRefreshContext { + codex_home: std::path::PathBuf, + auth_credentials_store_mode: AuthCredentialsStoreMode, + forced_chatgpt_workspace_id: Option, +} + +impl ChatgptAuthRefreshContext { + fn resolve_refresh_response( + &self, + params: &ChatgptAuthTokensRefreshParams, + ) -> Result { + let auth = load_auth_dot_json(&self.codex_home, self.auth_credentials_store_mode) + .map_err(|err| format!("failed to load local auth: {err}"))? + .ok_or_else(|| "no local auth available".to_string())?; + if matches!(auth.auth_mode, Some(AuthMode::ApiKey)) || auth.openai_api_key.is_some() { + return Err("local auth is not a ChatGPT login".to_string()); + } + + let tokens = auth + .tokens + .ok_or_else(|| "local ChatGPT auth is missing token data".to_string())?; + let access_token = tokens.access_token; + let chatgpt_account_id = tokens + .account_id + .or(tokens.id_token.chatgpt_account_id.clone()) + .ok_or_else(|| "local ChatGPT auth is missing chatgpt account id".to_string())?; + if let Some(expected_workspace) = self.forced_chatgpt_workspace_id.as_deref() + && chatgpt_account_id != expected_workspace + { + return Err(format!( + "local ChatGPT auth must use workspace {expected_workspace}, but found {chatgpt_account_id:?}" + )); + } + if let Some(previous_account_id) = params.previous_account_id.as_deref() + && previous_account_id != chatgpt_account_id + { + return Err(format!( + "local ChatGPT auth refresh account mismatch: expected `{previous_account_id}`, got `{chatgpt_account_id}`" + )); + } + + Ok(ChatgptAuthTokensRefreshResponse { + access_token, + chatgpt_account_id, + chatgpt_plan_type: tokens + .id_token + .get_chatgpt_plan_type() + .map(|plan_type| plan_type.to_ascii_lowercase()), + }) + } +} + impl InProcessAppServerClient { /// Starts the in-process runtime and facade worker task. /// @@ -338,9 +362,14 @@ 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 auto_handle_chatgpt_auth_refresh = args.auto_handle_chatgpt_auth_refresh; + let auth_refresh_context = ChatgptAuthRefreshContext { + codex_home: args.config.codex_home.clone().into(), + auth_credentials_store_mode: args.config.cli_auth_credentials_store_mode, + forced_chatgpt_workspace_id: args.config.forced_chatgpt_workspace_id.clone(), + }; 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 +430,54 @@ impl InProcessAppServerClient { let Some(event) = event else { break; }; + if auto_handle_chatgpt_auth_refresh + && let InProcessServerEvent::ServerRequest( + ServerRequest::ChatgptAuthTokensRefresh { request_id, params } + ) = &event + { + let response = tokio::task::spawn_blocking({ + let auth_refresh_context = auth_refresh_context.clone(); + let params = params.clone(); + move || auth_refresh_context.resolve_refresh_response(¶ms) + }) + .await; + let send_result = match response { + Ok(Ok(response)) => serde_json::to_value(response) + .map_err(|err| { + IoError::other(format!( + "failed to serialize chatgpt auth refresh response: {err}" + )) + }) + .and_then(|response| { + request_sender.respond_to_server_request( + request_id.clone(), + response, + ) + }), + Ok(Err(err)) => request_sender.fail_server_request( + request_id.clone(), + JSONRPCErrorError { + code: -32000, + message: err, + data: None, + }, + ), + Err(err) => request_sender.fail_server_request( + request_id.clone(), + JSONRPCErrorError { + code: -32000, + message: format!( + "chatgpt auth refresh task failed: {err}" + ), + data: None, + }, + ), + }; + if let Err(err) = send_result { + warn!("failed to auto-handle chatgpt auth refresh request: {err}"); + } + continue; + } if skipped_events > 0 { if event_requires_delivery(&event) { @@ -491,21 +568,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 +729,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 @@ -846,6 +909,9 @@ pub(crate) fn request_method_name(request: &ClientRequest) -> String { mod tests { use super::*; use codex_app_server_protocol::AccountUpdatedNotification; + use codex_app_server_protocol::AuthMode as ApiAuthMode; + use codex_app_server_protocol::ChatgptAuthTokensRefreshParams; + use codex_app_server_protocol::ChatgptAuthTokensRefreshReason; use codex_app_server_protocol::ConfigRequirementsReadResponse; use codex_app_server_protocol::GetAccountResponse; use codex_app_server_protocol::JSONRPCMessage; @@ -857,12 +923,17 @@ 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::auth::AuthCredentialsStoreMode; + use codex_core::auth::AuthDotJson; + use codex_core::auth::save_auth; use codex_core::config::ConfigBuilder; + use codex_core::token_data::TokenData; use futures::SinkExt; use futures::StreamExt; use pretty_assertions::assert_eq; + use std::fs; + use std::path::Path; + use std::path::PathBuf; use tokio::net::TcpListener; use tokio::time::Duration; use tokio::time::timeout; @@ -877,6 +948,53 @@ mod tests { } } + struct TestCodexHome { + path: PathBuf, + } + + impl TestCodexHome { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "codex-app-server-client-test-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock should be after unix epoch") + .as_nanos() + )); + fs::create_dir_all(&path).expect("test codex home should be created"); + Self { path } + } + } + + impl Drop for TestCodexHome { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + + fn fake_jwt() -> &'static str { + "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoid29ya3NwYWNlLTEiLCJjaGF0Z3B0X3BsYW5fdHlwZSI6ImJ1c2luZXNzIn19.c2ln" + } + + fn write_local_chatgpt_auth(codex_home: &Path) { + let id_token = fake_jwt(); + let access_token = fake_jwt().to_string(); + let auth = AuthDotJson { + auth_mode: Some(ApiAuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(TokenData { + id_token: codex_core::token_data::parse_chatgpt_jwt_claims(id_token) + .expect("id token should parse"), + access_token, + refresh_token: "refresh-token".to_string(), + account_id: Some("workspace-1".to_string()), + }), + last_refresh: None, + }; + save_auth(codex_home, &auth, AuthCredentialsStoreMode::File) + .expect("chatgpt auth should save"); + } + async fn start_test_client_with_capacity( session_source: SessionSource, channel_capacity: usize, @@ -894,6 +1012,8 @@ mod tests { client_name: "codex-app-server-client-test".to_string(), client_version: "0.0.0-test".to_string(), experimental_api: true, + auto_handle_chatgpt_auth_refresh: false, + allow_legacy_notifications: true, opt_out_notification_methods: Vec::new(), channel_capacity, }) @@ -1052,7 +1172,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 +1185,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 +1594,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 +1604,6 @@ mod tests { command_tx, event_rx, worker_handle, - auth_manager, - thread_manager, }; let event = timeout(Duration::from_secs(2), client.next_event()) @@ -1544,23 +1648,85 @@ mod tests { } #[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, + auto_handle_chatgpt_auth_refresh: true, + allow_legacy_notifications: false, + 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!(runtime_args.auth_manager.is_none()); + assert!(runtime_args.thread_manager.is_none()); + assert_eq!(runtime_args.allow_legacy_notifications, false); + assert_eq!(runtime_args.config, config); + } + + #[test] + fn chatgpt_auth_refresh_context_reads_local_auth() { + let codex_home = TestCodexHome::new(); + write_local_chatgpt_auth(&codex_home.path); + let context = ChatgptAuthRefreshContext { + codex_home: codex_home.path.clone(), + auth_credentials_store_mode: AuthCredentialsStoreMode::File, + forced_chatgpt_workspace_id: Some("workspace-1".to_string()), + }; + + let response = context + .resolve_refresh_response(&ChatgptAuthTokensRefreshParams { + reason: ChatgptAuthTokensRefreshReason::Unauthorized, + previous_account_id: Some("workspace-1".to_string()), + }) + .expect("local auth refresh should resolve"); + + assert_eq!( + response.chatgpt_account_id, "workspace-1", + "refresh response should preserve the local workspace id" + ); + assert_eq!(response.chatgpt_plan_type.as_deref(), Some("business")); + assert!(!response.access_token.is_empty()); + } + + #[test] + fn chatgpt_auth_refresh_context_rejects_workspace_mismatch() { + let codex_home = TestCodexHome::new(); + write_local_chatgpt_auth(&codex_home.path); + let context = ChatgptAuthRefreshContext { + codex_home: codex_home.path.clone(), + auth_credentials_store_mode: AuthCredentialsStoreMode::File, + forced_chatgpt_workspace_id: Some("workspace-2".to_string()), + }; + + let err = context + .resolve_refresh_response(&ChatgptAuthTokensRefreshParams { + reason: ChatgptAuthTokensRefreshReason::Unauthorized, + previous_account_id: Some("workspace-1".to_string()), + }) + .expect_err("workspace mismatch should fail"); + + assert_eq!( + err, + "local ChatGPT auth must use workspace workspace-2, but found \"workspace-1\"" + ); } #[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/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 4288d15393..81f775de4a 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -140,6 +140,9 @@ pub struct InProcessStartArgs { pub initialize: InitializeParams, /// Capacity used for all runtime queues (clamped to at least 1). pub channel_capacity: usize, + /// Whether the embedded connection should receive legacy `codex/event/*` + /// notifications in addition to typed app-server notifications. + pub allow_legacy_notifications: bool, } /// Event emitted from the app-server to the in-process client. @@ -390,7 +393,7 @@ 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, + args.allow_legacy_notifications, /*disconnect_sender*/ None, ), ); @@ -765,6 +768,7 @@ mod tests { config_warnings: Vec::new(), session_source, enable_codex_api_key_env: false, + allow_legacy_notifications: true, initialize: InitializeParams { client_info: ClientInfo { name: "codex-in-process-test".to_string(), diff --git a/codex-rs/exec/src/event_processor.rs b/codex-rs/exec/src/event_processor.rs index fc9d123e0d..b98c1fb034 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_core::config::Config; -use codex_protocol::protocol::Event; use codex_protocol::protocol::SessionConfiguredEvent; +use crate::typed_exec_event::TypedExecEvent; + pub(crate) enum CodexStatus { Running, InitiateShutdown, - Shutdown, } pub(crate) trait EventProcessor { @@ -20,7 +20,7 @@ pub(crate) trait EventProcessor { ); /// Handle a single event emitted by the agent. - fn process_event(&mut self, event: Event) -> CodexStatus; + fn process_event(&mut self, event: TypedExecEvent) -> 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 0e49166b81..48a0bf24c8 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,206 @@ +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::ThreadItem; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::TurnStatus; 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; +use crate::typed_exec_event::TypedExecEvent; -/// 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, - dimmed: Style, - - magenta: Style, - red: Style, - green: Style, cyan: Style, + dimmed: Style, + green: Style, + red: 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, + last_total_token_usage: Option, } impl EventProcessorWithHumanOutput { pub(crate) fn create_with_ansi( with_ansi: bool, - cursor_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()), + red: style(Style::new().red(), Style::new()), + yellow: style(Style::new().yellow(), Style::new()), + show_agent_reasoning: !config.hide_agent_reasoning, + last_message_path, + final_message: None, + 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 print_usage(&self) { + if let Some(usage) = &self.last_total_token_usage { + eprintln!( + "{} input={} cached={} output={}", + "usage:".style(self.dimmed), + usage.total.input_tokens, + usage.total.cached_input_tokens, + usage.total.output_tokens + ); + } + } + + fn render_item_started(&self, item: &ThreadItem) { + match item { + ThreadItem::CommandExecution { command, cwd, .. } => { + eprintln!( + "{} {} {}", + "exec:".style(self.bold), + command.style(self.cyan), + format!("({})", cwd.display()).style(self.dimmed) + ); } - } 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{}", "assistant".style(self.cyan), text); + self.final_message = Some(text); + } + ThreadItem::Reasoning { + summary, content, .. + } => { + if self.show_agent_reasoning { + let text = if content.is_empty() { + summary.join("\n") + } else { + content.join("\n") + }; + if !text.trim().is_empty() { + eprintln!("{}", text.style(self.dimmed)); + } + } + } + ThreadItem::CommandExecution { + command, + aggregated_output, + exit_code, + status, + .. + } => { + let status_text = match status { + CommandExecutionStatus::Completed => "completed".style(self.green), + CommandExecutionStatus::Failed => "failed".style(self.red), + CommandExecutionStatus::Declined => "declined".style(self.yellow), + CommandExecutionStatus::InProgress => "in_progress".style(self.dimmed), + }; + eprintln!( + "{} {} {}", + "exec:".style(self.bold), + command.style(self.cyan), + format!("({status_text})").style(self.dimmed) + ); + if let Some(exit_code) = exit_code { + eprintln!("{}", format!("exit code: {exit_code}").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)); + } + _ => {} + } + } + + fn sandbox_label(config: &Config) -> &'static str { + match config.permissions.sandbox_policy.get() { + SandboxPolicy::DangerFullAccess => "danger-full-access", + SandboxPolicy::ReadOnly { .. } => "read-only", + SandboxPolicy::ExternalSandbox { .. } => "external-sandbox", + SandboxPolicy::WorkspaceWrite { .. } => "workspace-write", } } } -#[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,978 +208,163 @@ 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 + eprintln!("OpenAI Codex v{VERSION} (research preview)\n--------"); + eprintln!( + "{} {}", + "model:".style(self.bold), + session_configured_event.model + ); + eprintln!( + "{} {}", + "provider:".style(self.bold), + session_configured_event.model_provider_id + ); + eprintln!( + "{} {}", + "sandbox:".style(self.bold), + Self::sandbox_label(config) + ); + eprintln!( + "{} {}", + "session id:".style(self.bold), + session_configured_event.session_id ); - - 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!("{} {}", 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, + fn process_event(&mut self, event: TypedExecEvent) -> CodexStatus { + match event { + TypedExecEvent::Warning(message) => { + eprintln!( "{} {message}", "warning:".style(self.yellow).style(self.bold) ); + 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, - "{} {}", - "elicitation request".style(self.magenta), - ev.server_name.style(self.dimmed) - ); - ts_msg!( - self, - "{}", - "auto-cancelling (not supported in exec mode)".style(self.dimmed) - ); - } - 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, - "{} {}", - "tool".style(self.magenta), - format_mcp_invocation(&invocation).style(self.bold), - ); - } - 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)); - } - } - } - 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, - "{} {}", - "image generation started".style(self.magenta), - generated.call_id - ); - } - 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 - ); - } - } - 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, - }, - ); - - 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}"); - } - } - } - } - } - } - 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)); - } - } - 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, - ); - } - } - 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)); - } - - // 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); - } - StepStatus::InProgress => { - ts_msg!(self, " {} {}", "→".style(self.cyan), item.step); - } - StepStatus::Pending => { - ts_msg!( - self, - " {} {}", - "•".style(self.dimmed), - item.step.style(self.dimmed) - ); - } - } - } - } - 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) - ); + TypedExecEvent::ConfigWarning(notification) => { + let details = notification + .details + .map(|details| format!(" ({details})")) + .unwrap_or_default(); eprintln!( - " receiver: {}", - receiver_thread_id.to_string().style(self.dimmed) + "{} {}{}", + "warning:".style(self.yellow).style(self.bold), + notification.summary, + details ); + CodexStatus::Running } - 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)); + TypedExecEvent::Error(notification) => { 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) + "ERROR:".style(self.red).style(self.bold), + notification.error ); + CodexStatus::Running + } + TypedExecEvent::DeprecationNotice(notification) => { 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) + "deprecated:".style(self.yellow).style(self.bold), + notification.summary ); + if let Some(details) = notification.details { + eprintln!("{}", details.style(self.dimmed)); + } + CodexStatus::Running } - 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)); + TypedExecEvent::HookStarted(notification) => { eprintln!( - " receiver: {}", - receiver_thread_id.to_string().style(self.dimmed) + "{} {}", + "hook:".style(self.bold), + format!("{:?}", notification.run.event_name).style(self.dimmed) ); + CodexStatus::Running } - 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(_) => {} + TypedExecEvent::HookCompleted(notification) => { + eprintln!( + "{} {} {:?}", + "hook:".style(self.bold), + format!("{:?}", notification.run.event_name).style(self.dimmed), + notification.run.status + ); + CodexStatus::Running + } + TypedExecEvent::ItemStarted(notification) => { + self.render_item_started(¬ification.item); + CodexStatus::Running + } + TypedExecEvent::ItemCompleted(notification) => { + self.render_item_completed(notification.item); + CodexStatus::Running + } + TypedExecEvent::ModelRerouted(notification) => { + eprintln!( + "{} {} -> {}", + "model rerouted:".style(self.yellow).style(self.bold), + notification.from_model, + notification.to_model + ); + CodexStatus::Running + } + TypedExecEvent::ThreadTokenUsageUpdated(notification) => { + self.last_total_token_usage = Some(notification.token_usage); + CodexStatus::Running + } + TypedExecEvent::TurnCompleted(notification) => match notification.turn.status { + TurnStatus::Completed => { + self.print_usage(); + CodexStatus::InitiateShutdown + } + TurnStatus::Failed => { + if let Some(error) = notification.turn.error { + eprintln!("{} {}", "ERROR:".style(self.red).style(self.bold), error); + } + CodexStatus::InitiateShutdown + } + TurnStatus::Interrupted => { + eprintln!("{}", "turn interrupted".style(self.dimmed)); + CodexStatus::InitiateShutdown + } + TurnStatus::InProgress => CodexStatus::Running, + }, + TypedExecEvent::TurnDiffUpdated(notification) => { + if !notification.diff.trim().is_empty() { + eprintln!("{}", notification.diff); + } + CodexStatus::Running + } + TypedExecEvent::TurnPlanUpdated(notification) => { + if let Some(explanation) = notification.explanation { + eprintln!("{}", explanation.style(self.dimmed)); + } + for step in notification.plan { + eprintln!("- {:?} {}", step.status, step.step); + } + CodexStatus::Running + } + TypedExecEvent::TurnStarted => CodexStatus::Running, } - CodexStatus::Running } fn print_final_output(&mut self) { - self.finish_progress_line(); - if let Some(usage_info) = &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()) - ); + if let Some(path) = self.last_message_path.as_deref() { + handle_last_message(self.final_message.as_deref(), path); } - // 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(), 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}"); } } } -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 render_hook_started(&self, event: HookStartedEvent) { - if !Self::should_print_hook_started(&event) { - return; - } - 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 - ); - } - } - - 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::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!(); - } - self.progress_anchor = false; - } - } - - 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 should_print_final_message_to_stdout( final_message: Option<&str>, stdout_is_terminal: bool, @@ -1165,293 +373,39 @@ 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})") - } -} - #[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 super::EventProcessorWithHumanOutput; use super::should_print_final_message_to_stdout; - use pretty_assertions::assert_eq; #[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 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, - ), - }; - - assert!(!EventProcessorWithHumanOutput::is_silent_event( - &EventMsg::HookStarted(event) )); } #[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), - }; - - assert!(EventProcessorWithHumanOutput::should_interrupt_progress( - &EventMsg::HookCompleted(event) - )); - } - - #[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, - ), - }; - - assert!(EventProcessorWithHumanOutput::is_silent_event( - &EventMsg::HookCompleted(event) - )); - } - - 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, - status, - status_message: status_message.map(ToOwned::to_owned), - started_at: 0, - completed_at: Some(1), - duration_ms: Some(1), - entries, - } + fn suppresses_final_stdout_message_when_missing() { + assert!(!should_print_final_message_to_stdout(None, false, false)); } } 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 33f44add77..a75eef8b7f 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -1,6 +1,20 @@ -use std::collections::HashMap; use std::path::PathBuf; use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; + +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::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; use crate::event_processor::CodexStatus; use crate::event_processor::EventProcessor; @@ -12,7 +26,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 +36,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 +52,15 @@ 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; +use crate::typed_exec_event::TypedExecEvent; 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, 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, } #[derive(Debug, Clone)] @@ -85,800 +69,436 @@ 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, -} - 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), 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, } } - 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(), - } + fn next_item_id(&self) -> String { + format!("item_{}", self.next_item_id.fetch_add(1, Ordering::SeqCst)) } - fn get_next_item_id(&self) -> String { - format!( - "item_{}", - self.next_event_id - .fetch_add(1, std::sync::atomic::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, + 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() + } - let (result, error) = match &ev.result { - Ok(value) => { - let result = McpToolCallItemResult { - content: value.content.clone(), - structured_content: value.structured_content.clone(), - }; - (Some(result), None) + fn map_item(item: ThreadItem) -> Option { + let id = item.id().to_string(); + let details = match item { + ThreadItem::AgentMessage { text, .. } => { + ThreadItemDetails::AgentMessage(AgentMessageItem { text }) } - Err(message) => ( - None, - Some(McpToolCallItemError { - message: message.clone(), - }), - ), - }; - - let item = ThreadItem { - id: item_id, - details: ThreadItemDetails::McpToolCall(McpToolCallItem { + ThreadItem::Reasoning { + summary, content, .. + } => { + let text = if content.is_empty() { + summary.join("\n") + } else { + content.join("\n") + }; + ThreadItemDetails::Reasoning(ReasoningItem { text }) + } + ThreadItem::CommandExecution { + command, + aggregated_output, + exit_code, + status, + .. + } => 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, + }, + }), + ThreadItem::FileChange { + changes, status, .. + } => 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, - }), - }; - - 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 { + .. + } => ThreadItemDetails::McpToolCall(McpToolCallItem { + server, tool, - sender_thread_id, - receiver_thread_ids, - prompt, - agents_states: HashMap::new(), - status: CollabToolCallStatus::InProgress, + 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::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, + .. + } => 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: match status { + CollabAgentToolCallStatus::InProgress => CollabToolCallStatus::InProgress, + CollabAgentToolCallStatus::Completed => CollabToolCallStatus::Completed, + CollabAgentToolCallStatus::Failed => CollabToolCallStatus::Failed, + }, }), - }; - 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), - }) - .collect(), - status, - }), - }; - - 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 })] - } - - fn todo_items_from_plan(&self, args: &UpdatePlanArgs) -> Vec { - args.plan - .iter() - .map(|p| TodoItem { - text: p.step.clone(), - completed: matches!(p.status, StepStatus::Completed), - }) - .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, + ThreadItem::WebSearch { query, action, .. } => { + ThreadItemDetails::WebSearch(WebSearchItem { + id: id.clone(), + 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, + }, + }) } - } else { - Usage::default() + _ => return None, }; - 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 })); - } - - items - } -} - -fn is_collab_failure(status: &CoreAgentStatus) -> bool { - matches!( - status, - CoreAgentStatus::Errored(_) | CoreAgentStatus::NotFound - ) -} - -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, - }, - } + Some(ExecThreadItem { id, details }) } } 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(ThreadEvent::ThreadStarted(ThreadStartedEvent { + thread_id: session_configured.session_id.to_string(), + })); } - #[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}"); + fn process_event(&mut self, event: TypedExecEvent) -> CodexStatus { + match event { + TypedExecEvent::Warning(message) => { + self.emit(ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: self.next_item_id(), + details: ThreadItemDetails::Error(ErrorItem { message }), + }, + })); + CodexStatus::Running + } + TypedExecEvent::ConfigWarning(notification) => { + let message = match notification.details { + Some(details) if !details.is_empty() => { + format!("{} ({details})", notification.summary) + } + _ => notification.summary, + }; + self.emit(ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: self.next_item_id(), + details: ThreadItemDetails::Error(ErrorItem { message }), + }, + })); + CodexStatus::Running + } + TypedExecEvent::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()); + self.emit(ThreadEvent::Error(error)); + CodexStatus::Running + } + TypedExecEvent::DeprecationNotice(notification) => { + let message = match notification.details { + Some(details) if !details.is_empty() => { + format!("{} ({details})", notification.summary) + } + _ => notification.summary, + }; + self.emit(ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: self.next_item_id(), + details: ThreadItemDetails::Error(ErrorItem { message }), + }, + })); + CodexStatus::Running + } + TypedExecEvent::HookStarted(_) | TypedExecEvent::HookCompleted(_) => { + CodexStatus::Running + } + TypedExecEvent::ItemStarted(notification) => { + if let Some(item) = Self::map_item(notification.item) { + self.emit(ThreadEvent::ItemStarted(ItemStartedEvent { item })); } - Err(e) => { - error!("Failed to serialize event: {e:?}"); + CodexStatus::Running + } + TypedExecEvent::ItemCompleted(notification) => { + if let Some(item) = Self::map_item(notification.item) { + if let ThreadItemDetails::AgentMessage(AgentMessageItem { text }) = + &item.details + { + self.final_message = Some(text.clone()); + } + self.emit(ThreadEvent::ItemCompleted(ItemCompletedEvent { item })); } + CodexStatus::Running + } + TypedExecEvent::ModelRerouted(notification) => { + self.emit(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 + } + TypedExecEvent::ThreadTokenUsageUpdated(notification) => { + self.last_total_token_usage = Some(notification.token_usage); + CodexStatus::Running + } + TypedExecEvent::TurnCompleted(notification) => { + if let Some(running) = self.running_todo_list.take() { + self.emit(ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: running.item_id, + details: ThreadItemDetails::TodoList(TodoListItem { + items: running.items, + }), + }, + })); + } + match notification.turn.status { + TurnStatus::Completed => { + self.emit(ThreadEvent::TurnCompleted(TurnCompletedEvent { + usage: self.usage_from_last_total(), + })); + CodexStatus::InitiateShutdown + } + TurnStatus::Failed => { + 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(), + }); + self.emit(ThreadEvent::TurnFailed(TurnFailedEvent { error })); + CodexStatus::InitiateShutdown + } + TurnStatus::Interrupted => CodexStatus::InitiateShutdown, + TurnStatus::InProgress => CodexStatus::Running, + } + } + TypedExecEvent::TurnDiffUpdated(_) => CodexStatus::Running, + TypedExecEvent::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(); + self.emit(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(), + }); + self.emit(ThreadEvent::ItemStarted(ItemStartedEvent { + item: ExecThreadItem { + id: item_id, + details: ThreadItemDetails::TodoList(TodoListItem { items }), + }, + })); + } + CodexStatus::Running + } + TypedExecEvent::TurnStarted => { + self.emit(ThreadEvent::TurnStarted(TurnStartedEvent {})); + CodexStatus::Running } } + } - let protocol::Event { msg, .. } = event; - - 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 let Some(path) = self.last_message_path.as_deref() { + handle_last_message(self.final_message.as_deref(), path); } } } + +#[cfg(test)] +mod tests { + use super::EventProcessorWithJsonOutput; + use crate::exec_events::TodoItem; + use codex_app_server_protocol::TurnPlanStep; + use codex_app_server_protocol::TurnPlanStepStatus; + use pretty_assertions::assert_eq; + + #[test] + 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!( + items, + vec![ + TodoItem { + text: "inspect bootstrap".to_string(), + completed: false, + }, + TodoItem { + text: "drop legacy notifications".to_string(), + completed: true, + }, + ] + ); + } +} diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index f648a63952..4884ad8525 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -9,6 +9,7 @@ mod event_processor; mod event_processor_with_human_output; pub mod event_processor_with_jsonl_output; pub mod exec_events; +mod typed_exec_event; pub use cli::Cli; pub use cli::Command; @@ -17,11 +18,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 +29,12 @@ 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::ThreadListParams; +use codex_app_server_protocol::ThreadListResponse; 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; @@ -41,8 +44,7 @@ use codex_app_server_protocol::TurnInterruptResponse; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; 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; @@ -62,11 +64,8 @@ use codex_core::git_info::get_git_repo_root; 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::SessionConfiguredEvent; @@ -79,8 +78,6 @@ 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::PathBuf; @@ -99,10 +96,10 @@ use uuid::Uuid; use crate::cli::Command as ExecCommand; use crate::event_processor::CodexStatus; use crate::event_processor::EventProcessor; +use crate::typed_exec_event::TypedExecEvent; +use crate::typed_exec_event::session_configured_from_thread_response; 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; @@ -287,18 +284,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(); @@ -450,6 +446,8 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result experimental_api: true, opt_out_notification_methods: Vec::new(), channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, + auto_handle_chatgpt_auth_refresh: true, + allow_legacy_notifications: false, }; run_exec_session(ExecRunArgs { in_process_start_args, @@ -500,14 +498,6 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { 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 +537,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 +587,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 +658,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::missing_system_bwrap_warning() { - let _ = event_processor.process_event(Event { - id: String::new(), - msg: EventMsg::Warning(codex_protocol::protocol::WarningEvent { message }), - }); + let _ = event_processor.process_event(TypedExecEvent::Warning(message)); } info!("Codex initialized with event: {session_configured:?}"); @@ -748,34 +733,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 +765,37 @@ 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 + 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() + + if should_process_notification( + ¬ification, + &primary_thread_id_for_requests, + &task_id, + ) && let Some(event) = TypedExecEvent::from_server_notification(notification) { - 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 - ); + match event_processor.process_event(event) { + CodexStatus::Running => {} + CodexStatus::InitiateShutdown => { if let Err(err) = request_shutdown( &client, &mut request_ids, @@ -859,38 +808,14 @@ 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 }), - }); + let _ = event_processor.process_event(TypedExecEvent::Warning(message)); } + InProcessServerEvent::LegacyNotification(_) => {} } } @@ -936,10 +861,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,56 +941,6 @@ fn session_configured_from_thread_resume_response( ) } -#[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, - rollout_path: Option, - model: String, - model_provider_id: String, - service_tier: Option, - approval_policy: AskForApproval, - approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, - sandbox_policy: codex_protocol::protocol::SandboxPolicy, - cwd: PathBuf, - reasoning_effort: Option, -) -> Result { - let session_id = codex_protocol::ThreadId::from_string(thread_id) - .map_err(|err| format!("thread id `{thread_id}` is invalid: {err}"))?; - - Ok(SessionConfiguredEvent { - session_id, - forked_from_id: None, - thread_name, - model, - model_provider_id, - service_tier, - approval_policy, - approvals_reviewer, - sandbox_policy, - cwd, - reasoning_effort, - history_log_id: 0, - history_entry_count: 0, - initial_messages: None, - network_proxy: None, - rollout_path, - }) -} - fn review_target_to_api(target: ReviewTarget) -> ApiReviewTarget { match target { ReviewTarget::UncommittedChanges => ApiReviewTarget::UncommittedChanges, @@ -1076,62 +950,157 @@ fn review_target_to_api(target: ReviewTarget) -> ApiReviewTarget { } } -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" - )); - }; - 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), - ); +fn all_thread_source_kinds() -> Vec { + vec![ + ThreadSourceKind::Cli, + ThreadSourceKind::VsCode, + ThreadSourceKind::Exec, + ThreadSourceKind::AppServer, + ThreadSourceKind::Custom, + ThreadSourceKind::SubAgent, + ThreadSourceKind::SubAgentReview, + ThreadSourceKind::SubAgentCompact, + ThreadSourceKind::SubAgentThreadSpawn, + ThreadSourceKind::SubAgentOther, + ThreadSourceKind::Unknown, + ] +} - 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, - }, - }) +async fn resolve_resume_thread_id( + client: &InProcessAppServerClient, + config: &Config, + args: &crate::cli::ResumeArgs, +) -> anyhow::Result> { + let filter_cwd = if args.all { + None + } else { + Some(config.cwd.to_string_lossy().to_string()) + }; + let model_providers = Some(vec![config.model_provider_id.clone()]); + + if args.last { + let response: ThreadListResponse = send_request_with_response( + client, + ClientRequest::ThreadList { + request_id: RequestId::Integer(0), + params: ThreadListParams { + cursor: None, + limit: Some(1), + sort_key: Some(ThreadSortKey::UpdatedAt), + model_providers, + source_kinds: Some(all_thread_source_kinds()), + archived: Some(false), + cwd: filter_cwd, + search_term: None, + }, + }, + "thread/list", + ) + .await + .map_err(anyhow::Error::msg)?; + return Ok(response.data.into_iter().next().map(|thread| thread.id)); + } + + 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: Some(vec![config.model_provider_id.clone()]), + source_kinds: Some(all_thread_source_kinds()), + archived: Some(false), + cwd: if args.all { + None + } else { + Some(config.cwd.to_string_lossy().to_string()) + }, + search_term: Some(session_id.to_string()), + }, + }, + "thread/list", + ) + .await + .map_err(anyhow::Error::msg)?; + if let Some(thread) = response + .data + .into_iter() + .find(|thread| thread.name.as_deref() == Some(session_id)) + { + return Ok(Some(thread.id)); + } + let Some(next_cursor) = response.next_cursor else { + return Ok(None); + }; + cursor = Some(next_cursor); + } } fn canceled_mcp_server_elicitation_response() -> Result { @@ -1205,8 +1174,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 +1195,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, @@ -1356,6 +1279,16 @@ async fn handle_server_request( ) .await } + ServerRequest::ChatgptAuthTokensRefresh { request_id, .. } => { + reject_server_request( + client, + request_id, + &method, + "chatgpt auth refresh should be handled by the in-process app-server client" + .to_string(), + ) + .await + } }; if let Err(err) = handle_result { @@ -1364,91 +1297,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,29 +1654,6 @@ 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"); - - 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" - )); - } - #[test] fn canceled_mcp_server_elicitation_response_uses_cancel_action() { let value = canceled_mcp_server_elicitation_response() diff --git a/codex-rs/exec/src/typed_exec_event.rs b/codex-rs/exec/src/typed_exec_event.rs new file mode 100644 index 0000000000..a4e95bd1ae --- /dev/null +++ b/codex-rs/exec/src/typed_exec_event.rs @@ -0,0 +1,124 @@ +use codex_app_server_protocol::ConfigWarningNotification; +use codex_app_server_protocol::DeprecationNoticeNotification; +use codex_app_server_protocol::ErrorNotification; +use codex_app_server_protocol::HookCompletedNotification; +use codex_app_server_protocol::HookStartedNotification; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::ModelReroutedNotification; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnDiffUpdatedNotification; +use codex_app_server_protocol::TurnPlanUpdatedNotification; +use codex_protocol::ThreadId; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionConfiguredEvent; +use std::path::PathBuf; + +#[derive(Debug, Clone)] +pub(crate) enum TypedExecEvent { + Warning(String), + ConfigWarning(ConfigWarningNotification), + Error(ErrorNotification), + DeprecationNotice(DeprecationNoticeNotification), + HookStarted(HookStartedNotification), + HookCompleted(HookCompletedNotification), + ItemStarted(ItemStartedNotification), + ItemCompleted(ItemCompletedNotification), + ModelRerouted(ModelReroutedNotification), + ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification), + TurnCompleted(TurnCompletedNotification), + TurnDiffUpdated(TurnDiffUpdatedNotification), + TurnPlanUpdated(TurnPlanUpdatedNotification), + TurnStarted, +} + +impl TypedExecEvent { + pub(crate) fn from_server_notification( + notification: ServerNotification, + ) -> Option { + match notification { + ServerNotification::ConfigWarning(notification) => { + Some(TypedExecEvent::ConfigWarning(notification)) + } + ServerNotification::DeprecationNotice(notification) => { + Some(TypedExecEvent::DeprecationNotice(notification)) + } + ServerNotification::Error(notification) => Some(TypedExecEvent::Error(notification)), + ServerNotification::HookCompleted(notification) => { + Some(TypedExecEvent::HookCompleted(notification)) + } + ServerNotification::HookStarted(notification) => { + Some(TypedExecEvent::HookStarted(notification)) + } + ServerNotification::ItemCompleted(notification) => { + Some(TypedExecEvent::ItemCompleted(notification)) + } + ServerNotification::ItemStarted(notification) => { + Some(TypedExecEvent::ItemStarted(notification)) + } + ServerNotification::ModelRerouted(notification) => { + Some(TypedExecEvent::ModelRerouted(notification)) + } + ServerNotification::ThreadTokenUsageUpdated(notification) => { + Some(TypedExecEvent::ThreadTokenUsageUpdated(notification)) + } + ServerNotification::TurnCompleted(notification) => { + Some(TypedExecEvent::TurnCompleted(notification)) + } + ServerNotification::TurnDiffUpdated(notification) => { + Some(TypedExecEvent::TurnDiffUpdated(notification)) + } + ServerNotification::TurnPlanUpdated(notification) => { + Some(TypedExecEvent::TurnPlanUpdated(notification)) + } + ServerNotification::TurnStarted(_) => Some(TypedExecEvent::TurnStarted), + _ => None, + } + } +} + +#[expect( + clippy::too_many_arguments, + reason = "session mapping keeps explicit fields" +)] +pub(crate) fn session_configured_from_thread_response( + thread_id: &str, + thread_name: Option, + rollout_path: Option, + model: String, + model_provider_id: String, + service_tier: Option, + approval_policy: AskForApproval, + approvals_reviewer: ApprovalsReviewer, + sandbox_policy: SandboxPolicy, + cwd: PathBuf, + reasoning_effort: Option, +) -> Result { + let session_id = ThreadId::from_string(thread_id) + .map_err(|err| format!("thread id `{thread_id}` is invalid: {err}"))?; + + Ok(SessionConfiguredEvent { + session_id, + forked_from_id: None, + thread_name, + model, + model_provider_id, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_policy, + cwd, + reasoning_effort, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path, + }) +} diff --git a/codex-rs/exec/tests/all.rs b/codex-rs/exec/tests/all.rs index 6fd2c163a2..7e136e4cce 100644 --- a/codex-rs/exec/tests/all.rs +++ b/codex-rs/exec/tests/all.rs @@ -1,5 +1,3 @@ // Single integration test binary that aggregates all test modules. // The submodules live in `tests/suite/`. mod suite; - -mod event_processor_with_json_output; diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs deleted file mode 100644 index e9f295337e..0000000000 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ /dev/null @@ -1,1318 +0,0 @@ -use codex_exec::event_processor_with_jsonl_output::EventProcessorWithJsonOutput; -use codex_exec::exec_events::AgentMessageItem; -use codex_exec::exec_events::CollabAgentState; -use codex_exec::exec_events::CollabAgentStatus; -use codex_exec::exec_events::CollabTool; -use codex_exec::exec_events::CollabToolCallItem; -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::ItemCompletedEvent; -use codex_exec::exec_events::ItemStartedEvent; -use codex_exec::exec_events::ItemUpdatedEvent; -use codex_exec::exec_events::McpToolCallItem; -use codex_exec::exec_events::McpToolCallItemError; -use codex_exec::exec_events::McpToolCallItemResult; -use codex_exec::exec_events::McpToolCallStatus; -use codex_exec::exec_events::PatchApplyStatus; -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::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::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(), - }), - )); - - 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, - 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(), - }), - ] - ); -} - -#[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" })), - }; - - 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, - }), - }, - })] - ); - - 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); - 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, - }), - error: None, - status: McpToolCallStatus::Completed, - }), - }, - })] - ); -} - -#[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 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, - }), - }, - })] - ); -} - -#[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 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 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" })), - }), - error: None, - status: McpToolCallStatus::Completed, - }), - }, - })] - ); -} - -#[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 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 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 { - 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(), - }), - }, - })] - ); -} - -#[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), - }), - )); - assert_eq!( - out, - vec![ThreadEvent::Error(ThreadErrorEvent { - message: "boom".to_string(), - })] - ); -} - -#[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(), - }), - )); - 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(), - }), - }, - })] - ); -} - -#[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, - }), - )); - 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:?}"), - } - } - 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, - }, - })] - ); -} diff --git a/codex-rs/tui_app_server/src/lib.rs b/codex-rs/tui_app_server/src/lib.rs index 17e309d5fb..09e626e757 100644 --- a/codex-rs/tui_app_server/src/lib.rs +++ b/codex-rs/tui_app_server/src/lib.rs @@ -438,6 +438,8 @@ where client_name: "codex-tui".to_string(), client_version: env!("CARGO_PKG_VERSION").to_string(), experimental_api: true, + auto_handle_chatgpt_auth_refresh: false, + allow_legacy_notifications: true, opt_out_notification_methods: Vec::new(), channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }) diff --git a/codex-rs/tui_app_server/src/onboarding/auth.rs b/codex-rs/tui_app_server/src/onboarding/auth.rs index 612fdbe2ac..932b296424 100644 --- a/codex-rs/tui_app_server/src/onboarding/auth.rs +++ b/codex-rs/tui_app_server/src/onboarding/auth.rs @@ -922,6 +922,8 @@ mod tests { client_name: "test".to_string(), client_version: "test".to_string(), experimental_api: true, + auto_handle_chatgpt_auth_refresh: false, + allow_legacy_notifications: true, opt_out_notification_methods: Vec::new(), channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, })