diff --git a/codex-rs/app-server-protocol/schema/json/EventMsg.json b/codex-rs/app-server-protocol/schema/json/EventMsg.json index 6636278e19..e17940056b 100644 --- a/codex-rs/app-server-protocol/schema/json/EventMsg.json +++ b/codex-rs/app-server-protocol/schema/json/EventMsg.json @@ -6281,7 +6281,7 @@ "type": "object" }, { - "description": "Explicit mention selected by the user (name + app://connector id).", + "description": "Explicit structured mention selected by the user.\n\n`path` identifies the exact mention target, for example `app://` or `plugin://@`.", "properties": { "name": { "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 0bebb007cb..2823261f96 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -11621,12 +11621,6 @@ "null" ] }, - "enabled": { - "type": [ - "boolean", - "null" - ] - }, "httpPort": { "format": "uint16", "minimum": 0.0, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index da67d650c4..b0d209fac5 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -8116,12 +8116,6 @@ "null" ] }, - "enabled": { - "type": [ - "boolean", - "null" - ] - }, "httpPort": { "format": "uint16", "minimum": 0.0, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index 55bc3c62c9..a90fa416b7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -147,12 +147,6 @@ "null" ] }, - "enabled": { - "type": [ - "boolean", - "null" - ] - }, "httpPort": { "format": "uint16", "minimum": 0.0, diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/NetworkRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkRequirements.ts index 1f1653c277..9047a27d73 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/NetworkRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkRequirements.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type NetworkRequirements = { enabled: boolean | null, httpPort: number | null, socksPort: number | null, allowUpstreamProxy: boolean | null, dangerouslyAllowNonLoopbackProxy: boolean | null, dangerouslyAllowAllUnixSockets: boolean | null, allowedDomains: Array | null, deniedDomains: Array | null, allowUnixSockets: Array | null, allowLocalBinding: boolean | null, }; +export type NetworkRequirements = { httpPort: number | null, socksPort: number | null, allowUpstreamProxy: boolean | null, dangerouslyAllowNonLoopbackProxy: boolean | null, dangerouslyAllowAllUnixSockets: boolean | null, allowedDomains: Array | null, deniedDomains: Array | null, allowUnixSockets: Array | null, allowLocalBinding: boolean | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index c65c41d1a7..649e8d664e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -624,7 +624,6 @@ pub struct ConfigRequirements { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct NetworkRequirements { - pub enabled: Option, pub http_port: Option, pub socks_port: Option, pub allow_upstream_proxy: Option, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 799bec4037..49d216099c 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -130,7 +130,7 @@ Example with notification opt-out: - `thread/status/changed` — notification emitted when a loaded thread’s status changes (`threadId` + new `status`). - `thread/archive` — move a thread’s rollout file into the archived directory; returns `{}` on success and emits `thread/archived`. - `thread/unsubscribe` — unsubscribe this connection from thread turn/item events. If this was the last subscriber, the server shuts down and unloads the thread, then emits `thread/closed`. -- `thread/name/set` — set or update a thread’s user-facing name for either a loaded thread or a persisted rollout; returns `{}` on success. Thread names are not required to be unique; name lookups resolve to the most recently updated thread. +- `thread/name/set` — set or update a thread’s user-facing name for either a loaded thread or a persisted rollout; returns `{}` on success and emits `thread/name/updated` to initialized, opted-in clients. Thread names are not required to be unique; name lookups resolve to the most recently updated thread. - `thread/unarchive` — move an archived rollout file back into the sessions directory; returns the restored `thread` on success and emits `thread/unarchived`. - `thread/compact/start` — trigger conversation history compaction for a thread; returns `{}` immediately while progress streams through standard turn/item notifications. - `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted. @@ -476,6 +476,26 @@ Invoke an app by including `$` in the text input and adding a `mention } } } ``` +### Example: Start a turn (invoke a plugin) + +Invoke a plugin by including a UI mention token such as `@sample` in the text input and adding a `mention` input item with the exact `plugin://@` path returned by `plugin/list`. + +```json +{ "method": "turn/start", "id": 35, "params": { + "threadId": "thr_123", + "input": [ + { "type": "text", "text": "@sample Summarize the latest updates." }, + { "type": "mention", "name": "Sample Plugin", "path": "plugin://sample@test" } + ] +} } +{ "id": 35, "result": { "turn": { + "id": "turn_459", + "status": "inProgress", + "items": [], + "error": null +} } } +``` + ### Example: Interrupt an active turn You can cancel a running Turn with `turn/interrupt`. @@ -976,7 +996,7 @@ The server also emits `app/list/updated` notifications whenever either source (a } ``` -Invoke an app by inserting `$` in the text input. The slug is derived from the app name and lowercased with non-alphanumeric characters replaced by `-` (for example, "Demo App" becomes `$demo-app`). Add a `mention` input item (recommended) so the server uses the exact `app://` path rather than guessing by name. +Invoke an app by inserting `$` in the text input. The slug is derived from the app name and lowercased with non-alphanumeric characters replaced by `-` (for example, "Demo App" becomes `$demo-app`). Add a `mention` input item (recommended) so the server uses the exact `app://` path rather than guessing by name. Plugins use the same `mention` item shape, but with `plugin://@` paths from `plugin/list`. Example: diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 33ae38c73f..499ac9ffc7 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1590,7 +1590,9 @@ pub(crate) async fn apply_bespoke_event_handling( thread_name: thread_name_event.thread_name, }; outgoing - .send_server_notification(ServerNotification::ThreadNameUpdated(notification)) + .send_global_server_notification(ServerNotification::ThreadNameUpdated( + notification, + )) .await; } } diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index 2f2f5a51f5..3e64508d16 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -158,7 +158,6 @@ fn map_network_requirements_to_api( network: codex_core::config_loader::NetworkRequirementsToml, ) -> NetworkRequirements { NetworkRequirements { - enabled: network.enabled, http_port: network.http_port, socks_port: network.socks_port, allow_upstream_proxy: network.allow_upstream_proxy, @@ -224,7 +223,6 @@ mod tests { rules: None, enforce_residency: Some(CoreResidencyRequirement::Us), network: Some(CoreNetworkRequirementsToml { - enabled: Some(true), http_port: Some(8080), socks_port: Some(1080), allow_upstream_proxy: Some(false), @@ -268,7 +266,6 @@ mod tests { assert_eq!( mapped.network, Some(NetworkRequirements { - enabled: Some(true), http_port: Some(8080), socks_port: Some(1080), allow_upstream_proxy: Some(false), diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 368d5d21a0..bc38bd36c1 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -101,6 +101,10 @@ impl ThreadScopedOutgoingMessageSender { .await; } + pub(crate) async fn send_global_server_notification(&self, notification: ServerNotification) { + self.outgoing.send_server_notification(notification).await; + } + pub(crate) async fn abort_pending_server_requests(&self) { self.outgoing .cancel_requests_for_thread( diff --git a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs index 9799945383..26fffd8a13 100644 --- a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs +++ b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs @@ -6,6 +6,7 @@ use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCRequest; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -202,6 +203,56 @@ pub(super) async fn read_response_for_id( } } +pub(super) async fn read_notification_for_method( + stream: &mut WsClient, + method: &str, +) -> Result { + loop { + let message = read_jsonrpc_message(stream).await?; + if let JSONRPCMessage::Notification(notification) = message + && notification.method == method + { + return Ok(notification); + } + } +} + +pub(super) async fn read_response_and_notification_for_method( + stream: &mut WsClient, + id: i64, + method: &str, +) -> Result<(JSONRPCResponse, JSONRPCNotification)> { + let target_id = RequestId::Integer(id); + let mut response = None; + let mut notification = None; + + while response.is_none() || notification.is_none() { + let message = read_jsonrpc_message(stream).await?; + match message { + JSONRPCMessage::Response(candidate) if candidate.id == target_id => { + response = Some(candidate); + } + JSONRPCMessage::Notification(candidate) if candidate.method == method => { + if notification.replace(candidate).is_some() { + bail!( + "received duplicate notification for method `{method}` before completing paired read" + ); + } + } + _ => {} + } + } + + let Some(response) = response else { + bail!("response must be set before returning"); + }; + let Some(notification) = notification else { + bail!("notification must be set before returning"); + }; + + Ok((response, notification)) +} + async fn read_error_for_id(stream: &mut WsClient, id: i64) -> Result { let target_id = RequestId::Integer(id); loop { @@ -237,7 +288,7 @@ async fn read_jsonrpc_message(stream: &mut WsClient) -> Result { } } -async fn assert_no_message(stream: &mut WsClient, wait_for: Duration) -> Result<()> { +pub(super) async fn assert_no_message(stream: &mut WsClient, wait_for: Duration) -> Result<()> { match timeout(wait_for, stream.next()).await { Ok(Some(Ok(frame))) => bail!("unexpected frame while waiting for silence: {frame:?}"), Ok(Some(Err(err))) => bail!("unexpected websocket read error: {err}"), diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 283928d6aa..f85849250d 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -28,6 +28,7 @@ mod thread_fork; mod thread_list; mod thread_loaded_list; mod thread_metadata_update; +mod thread_name_websocket; mod thread_read; mod thread_resume; mod thread_rollback; diff --git a/codex-rs/app-server/tests/suite/v2/thread_name_websocket.rs b/codex-rs/app-server/tests/suite/v2/thread_name_websocket.rs new file mode 100644 index 0000000000..f2c03cb940 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/thread_name_websocket.rs @@ -0,0 +1,171 @@ +use super::connection_handling_websocket::DEFAULT_READ_TIMEOUT; +use super::connection_handling_websocket::WsClient; +use super::connection_handling_websocket::assert_no_message; +use super::connection_handling_websocket::connect_websocket; +use super::connection_handling_websocket::create_config_toml; +use super::connection_handling_websocket::read_notification_for_method; +use super::connection_handling_websocket::read_response_and_notification_for_method; +use super::connection_handling_websocket::read_response_for_id; +use super::connection_handling_websocket::reserve_local_addr; +use super::connection_handling_websocket::send_initialize_request; +use super::connection_handling_websocket::send_request; +use super::connection_handling_websocket::spawn_websocket_server; +use anyhow::Context; +use anyhow::Result; +use app_test_support::create_fake_rollout_with_text_elements; +use app_test_support::create_mock_responses_server_repeating_assistant; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::ThreadNameUpdatedNotification; +use codex_app_server_protocol::ThreadResumeParams; +use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadSetNameParams; +use codex_app_server_protocol::ThreadSetNameResponse; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::Duration; +use tokio::time::timeout; + +#[tokio::test] +async fn thread_name_updated_broadcasts_for_loaded_threads() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let conversation_id = create_rollout(codex_home.path(), "2025-01-05T12-00-00")?; + + let bind_addr = reserve_local_addr()?; + let mut process = spawn_websocket_server(codex_home.path(), bind_addr).await?; + + let result = async { + let mut ws1 = connect_websocket(bind_addr).await?; + let mut ws2 = connect_websocket(bind_addr).await?; + initialize_both_clients(&mut ws1, &mut ws2).await?; + + send_request( + &mut ws1, + "thread/resume", + 10, + Some(serde_json::to_value(ThreadResumeParams { + thread_id: conversation_id.clone(), + ..Default::default() + })?), + ) + .await?; + let resume_resp: JSONRPCResponse = read_response_for_id(&mut ws1, 10).await?; + let resume: ThreadResumeResponse = to_response::(resume_resp)?; + assert_eq!(resume.thread.id, conversation_id); + + let renamed = "Loaded rename"; + send_request( + &mut ws1, + "thread/name/set", + 11, + Some(serde_json::to_value(ThreadSetNameParams { + thread_id: conversation_id.clone(), + name: renamed.to_string(), + })?), + ) + .await?; + let (rename_resp, ws1_notification) = + read_response_and_notification_for_method(&mut ws1, 11, "thread/name/updated").await?; + let _: ThreadSetNameResponse = to_response::(rename_resp)?; + assert_thread_name_updated(ws1_notification, &conversation_id, renamed)?; + + let ws2_notification = + read_notification_for_method(&mut ws2, "thread/name/updated").await?; + assert_thread_name_updated(ws2_notification, &conversation_id, renamed)?; + + assert_no_message(&mut ws1, Duration::from_millis(250)).await?; + assert_no_message(&mut ws2, Duration::from_millis(250)).await?; + Ok(()) + } + .await; + + process + .kill() + .await + .context("failed to stop websocket app-server process")?; + result +} + +#[tokio::test] +async fn thread_name_updated_broadcasts_for_not_loaded_threads() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let conversation_id = create_rollout(codex_home.path(), "2025-01-05T12-05-00")?; + + let bind_addr = reserve_local_addr()?; + let mut process = spawn_websocket_server(codex_home.path(), bind_addr).await?; + + let result = async { + let mut ws1 = connect_websocket(bind_addr).await?; + let mut ws2 = connect_websocket(bind_addr).await?; + initialize_both_clients(&mut ws1, &mut ws2).await?; + + let renamed = "Stored rename"; + send_request( + &mut ws1, + "thread/name/set", + 20, + Some(serde_json::to_value(ThreadSetNameParams { + thread_id: conversation_id.clone(), + name: renamed.to_string(), + })?), + ) + .await?; + let (rename_resp, ws1_notification) = + read_response_and_notification_for_method(&mut ws1, 20, "thread/name/updated").await?; + let _: ThreadSetNameResponse = to_response::(rename_resp)?; + assert_thread_name_updated(ws1_notification, &conversation_id, renamed)?; + + let ws2_notification = + read_notification_for_method(&mut ws2, "thread/name/updated").await?; + assert_thread_name_updated(ws2_notification, &conversation_id, renamed)?; + + assert_no_message(&mut ws1, Duration::from_millis(250)).await?; + assert_no_message(&mut ws2, Duration::from_millis(250)).await?; + Ok(()) + } + .await; + + process + .kill() + .await + .context("failed to stop websocket app-server process")?; + result +} + +async fn initialize_both_clients(ws1: &mut WsClient, ws2: &mut WsClient) -> Result<()> { + send_initialize_request(ws1, 1, "ws_client_one").await?; + timeout(DEFAULT_READ_TIMEOUT, read_response_for_id(ws1, 1)).await??; + + send_initialize_request(ws2, 2, "ws_client_two").await?; + timeout(DEFAULT_READ_TIMEOUT, read_response_for_id(ws2, 2)).await??; + Ok(()) +} + +fn create_rollout(codex_home: &std::path::Path, filename_ts: &str) -> Result { + create_fake_rollout_with_text_elements( + codex_home, + filename_ts, + "2025-01-05T12:00:00Z", + "Saved user message", + Vec::new(), + Some("mock_provider"), + None, + ) +} + +fn assert_thread_name_updated( + notification: JSONRPCNotification, + thread_id: &str, + thread_name: &str, +) -> Result<()> { + let notification: ThreadNameUpdatedNotification = + serde_json::from_value(notification.params.context("thread/name/updated params")?)?; + assert_eq!(notification.thread_id, thread_id); + assert_eq!(notification.thread_name.as_deref(), Some(thread_name)); + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs index bdbb283432..214cbbd73a 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs @@ -61,13 +61,22 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> { }; eprintln!("using zsh path for zsh-fork test: {}", zsh_path.display()); - let responses = vec![create_shell_command_sse_response( + let response = create_shell_command_sse_response( vec!["echo".to_string(), "hi".to_string()], None, Some(5000), "call-zsh-fork", - )?]; - let server = create_mock_responses_server_sequence(responses).await; + )?; + let no_op_response = responses::sse(vec![ + responses::ev_response_created("resp-2"), + responses::ev_completed("resp-2"), + ]); + // Interrupting after the shell item starts can race with the follow-up + // model request that reports the aborted tool call. This test only cares + // that zsh-fork launches the expected command, so allow one extra no-op + // `/responses` POST instead of asserting an exact request count. + let server = + create_mock_responses_server_sequence_unchecked(vec![response, no_op_response]).await; create_config_toml( &codex_home, &server.uri(), diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 0f51a99537..81f83cc358 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -290,6 +290,9 @@ use crate::tools::sandboxing::ApprovalStore; use crate::tools::spec::ToolsConfig; use crate::tools::spec::ToolsConfigParams; use crate::turn_diff_tracker::TurnDiffTracker; +use crate::turn_timing::TurnTimingState; +use crate::turn_timing::record_turn_ttfm_metric; +use crate::turn_timing::record_turn_ttft_metric; use crate::unified_exec::UnifiedExecProcessManager; use crate::util::backoff; use crate::windows_sandbox::WindowsSandboxLevelExt; @@ -694,6 +697,7 @@ pub(crate) struct TurnContext { pub(crate) dynamic_tools: Vec, pub(crate) turn_metadata_state: Arc, pub(crate) turn_skills: TurnSkillsContext, + pub(crate) turn_timing_state: Arc, } impl TurnContext { pub(crate) fn model_context_window(&self) -> Option { @@ -783,6 +787,7 @@ impl TurnContext { dynamic_tools: self.dynamic_tools.clone(), turn_metadata_state: self.turn_metadata_state.clone(), turn_skills: self.turn_skills.clone(), + turn_timing_state: Arc::clone(&self.turn_timing_state), } } @@ -1165,6 +1170,7 @@ impl Session { dynamic_tools: session_configuration.dynamic_tools.clone(), turn_metadata_state, turn_skills: TurnSkillsContext::new(skills_outcome), + turn_timing_state: Arc::new(TurnTimingState::default()), } } @@ -1688,7 +1694,7 @@ impl Session { self.services.state_db.clone() } - /// Ensure all rollout writes are durably flushed. + /// Ensure rollout file writes are durably flushed. pub(crate) async fn flush_rollout(&self) { let recorder = { let guard = self.services.rollout.lock().await; @@ -2488,6 +2494,7 @@ impl Session { turn_context: &TurnContext, item: TurnItem, ) { + record_turn_ttfm_metric(turn_context, &item).await; self.send_event( turn_context, EventMsg::ItemCompleted(ItemCompletedEvent { @@ -4988,6 +4995,7 @@ async fn spawn_review_thread( truncation_policy: model_info.truncation_policy.into(), turn_metadata_state, turn_skills: TurnSkillsContext::new(parent_turn_context.turn_skills.outcome.clone()), + turn_timing_state: Arc::new(TurnTimingState::default()), }; // Seed the child task with the review prompt as the initial user message. @@ -5121,7 +5129,7 @@ pub(crate) async fn run_turn( .services .plugins_manager .plugins_for_config(&turn_context.config); - // Plain-text @plugin mentions are resolved from the current session's + // Structured plugin:// mentions are resolved from the current session's // enabled plugins, then converted into turn-scoped guidance below. let mentioned_plugins = collect_explicit_plugin_mentions(&input, loaded_plugins.capability_summaries()); @@ -5218,7 +5226,7 @@ pub(crate) async fn run_turn( &available_connectors, &skill_name_counts_lower, )); - // Explicit @plugin mentions can make a plugin's enabled apps callable for + // Explicit plugin mentions can make a plugin's enabled apps callable for // this turn without persisting those connectors as sticky user selections. let mut turn_enabled_connectors = explicitly_enabled_connectors.clone(); turn_enabled_connectors.extend( @@ -6583,6 +6591,7 @@ async fn try_run_sampling_request( sess.services .otel_manager .record_responses(&handle_responses, &event); + record_turn_ttft_metric(&turn_context, &event).await; match event { ResponseEvent::Created => {} @@ -6853,3958 +6862,5 @@ pub(crate) use tests::make_session_and_context_with_rx; pub(crate) use tests::make_session_configuration_for_tests; #[cfg(test)] -mod tests { - use super::*; - use crate::CodexAuth; - use crate::config::ConfigBuilder; - use crate::config::test_config; - use crate::config_loader::ConfigLayerStack; - use crate::config_loader::ConfigLayerStackOrdering; - use crate::config_loader::NetworkConstraints; - use crate::config_loader::RequirementSource; - use crate::config_loader::Sourced; - use crate::exec::ExecToolCallOutput; - use crate::function_tool::FunctionCallError; - use crate::mcp_connection_manager::ToolInfo; - use crate::models_manager::model_info; - use crate::shell::default_user_shell; - use crate::tools::format_exec_output_str; - - use codex_protocol::ThreadId; - use codex_protocol::models::FunctionCallOutputBody; - use codex_protocol::models::FunctionCallOutputPayload; - use tracing::Span; - - use crate::protocol::CompactedItem; - use crate::protocol::CreditsSnapshot; - use crate::protocol::InitialHistory; - use crate::protocol::NetworkApprovalProtocol; - use crate::protocol::RateLimitSnapshot; - use crate::protocol::RateLimitWindow; - use crate::protocol::ResumedHistory; - use crate::protocol::TokenCountEvent; - use crate::protocol::TokenUsage; - use crate::protocol::TokenUsageInfo; - use crate::protocol::TurnCompleteEvent; - use crate::protocol::UserMessageEvent; - use crate::rollout::policy::EventPersistenceMode; - use crate::rollout::recorder::RolloutRecorder; - use crate::rollout::recorder::RolloutRecorderParams; - use crate::state::TaskKind; - use crate::tasks::SessionTask; - use crate::tasks::SessionTaskContext; - use crate::tools::ToolRouter; - use crate::tools::context::ToolInvocation; - use crate::tools::context::ToolOutput; - use crate::tools::context::ToolPayload; - use crate::tools::handlers::ShellHandler; - use crate::tools::handlers::UnifiedExecHandler; - use crate::tools::registry::ToolHandler; - use crate::tools::router::ToolCallSource; - use crate::turn_diff_tracker::TurnDiffTracker; - use codex_app_server_protocol::AppInfo; - use codex_otel::TelemetryAuthMode; - use codex_protocol::models::BaseInstructions; - use codex_protocol::models::ContentItem; - use codex_protocol::models::ResponseInputItem; - use codex_protocol::models::ResponseItem; - use codex_protocol::openai_models::ModelsResponse; - use codex_protocol::protocol::ConversationAudioParams; - use codex_protocol::protocol::RealtimeAudioFrame; - use codex_protocol::protocol::Submission; - use codex_protocol::protocol::W3cTraceContext; - use opentelemetry::trace::TraceContextExt; - use opentelemetry::trace::TraceId; - use opentelemetry::trace::TracerProvider as _; - use opentelemetry_sdk::trace::SdkTracerProvider; - use std::path::Path; - use std::time::Duration; - use tokio::time::sleep; - use tracing_opentelemetry::OpenTelemetrySpanExt; - use tracing_subscriber::prelude::*; - - use codex_protocol::mcp::CallToolResult as McpCallToolResult; - use pretty_assertions::assert_eq; - use rmcp::model::JsonObject; - use rmcp::model::Tool; - use serde::Deserialize; - use serde_json::json; - use std::path::PathBuf; - use std::sync::Arc; - use std::sync::Once; - use std::time::Duration as StdDuration; - - struct InstructionsTestCase { - slug: &'static str, - expects_apply_patch_instructions: bool, - } - - fn user_message(text: &str) -> ResponseItem { - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: text.to_string(), - }], - end_turn: None, - phase: None, - } - } - - fn assistant_message(text: &str) -> ResponseItem { - ResponseItem::Message { - id: None, - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: text.to_string(), - }], - end_turn: None, - phase: None, - } - } - - fn skill_message(text: &str) -> ResponseItem { - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: text.to_string(), - }], - end_turn: None, - phase: None, - } - } - - fn developer_input_texts(items: &[ResponseItem]) -> Vec<&str> { - items - .iter() - .filter_map(|item| match item { - ResponseItem::Message { role, content, .. } if role == "developer" => { - Some(content.as_slice()) - } - _ => None, - }) - .flat_map(|content| content.iter()) - .filter_map(|item| match item { - ContentItem::InputText { text } => Some(text.as_str()), - _ => None, - }) - .collect() - } - - fn make_connector(id: &str, name: &str) -> AppInfo { - AppInfo { - id: id.to_string(), - name: name.to_string(), - description: None, - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: None, - is_accessible: true, - is_enabled: true, - plugin_display_names: Vec::new(), - } - } - - #[test] - fn assistant_message_stream_parsers_can_be_seeded_from_output_item_added_text() { - let mut parsers = AssistantMessageStreamParsers::new(false); - let item_id = "msg-1"; - - let seeded = parsers.seed_item_text(item_id, "hello doc"); - let parsed = parsers.parse_delta(item_id, "1 world"); - let tail = parsers.finish_item(item_id); - - assert_eq!(seeded.visible_text, "hello "); - assert_eq!(seeded.citations, Vec::::new()); - assert_eq!(parsed.visible_text, " world"); - assert_eq!(parsed.citations, vec!["doc1".to_string()]); - assert_eq!(tail.visible_text, ""); - assert_eq!(tail.citations, Vec::::new()); - } - - #[test] - fn assistant_message_stream_parsers_seed_buffered_prefix_stays_out_of_finish_tail() { - let mut parsers = AssistantMessageStreamParsers::new(false); - let item_id = "msg-1"; - - let seeded = parsers.seed_item_text(item_id, "hello doc world"); - let tail = parsers.finish_item(item_id); - - assert_eq!(seeded.visible_text, "hello "); - assert_eq!(seeded.citations, Vec::::new()); - assert_eq!(parsed.visible_text, " world"); - assert_eq!(parsed.citations, vec!["doc".to_string()]); - assert_eq!(tail.visible_text, ""); - assert_eq!(tail.citations, Vec::::new()); - } - - #[test] - fn assistant_message_stream_parsers_seed_plan_parser_across_added_and_delta_boundaries() { - let mut parsers = AssistantMessageStreamParsers::new(true); - let item_id = "msg-1"; - - let seeded = parsers.seed_item_text(item_id, "Intro\n\n- step\n\nOutro"); - let tail = parsers.finish_item(item_id); - - assert_eq!(seeded.visible_text, "Intro\n"); - assert_eq!( - seeded.plan_segments, - vec![ProposedPlanSegment::Normal("Intro\n".to_string())] - ); - assert_eq!(parsed.visible_text, "Outro"); - assert_eq!( - parsed.plan_segments, - vec![ - ProposedPlanSegment::ProposedPlanStart, - ProposedPlanSegment::ProposedPlanDelta("- step\n".to_string()), - ProposedPlanSegment::ProposedPlanEnd, - ProposedPlanSegment::Normal("Outro".to_string()), - ] - ); - assert_eq!(tail.visible_text, ""); - assert!(tail.plan_segments.is_empty()); - } - - fn make_mcp_tool( - server_name: &str, - tool_name: &str, - connector_id: Option<&str>, - connector_name: Option<&str>, - ) -> ToolInfo { - ToolInfo { - server_name: server_name.to_string(), - tool_name: tool_name.to_string(), - tool: Tool { - name: tool_name.to_string().into(), - title: None, - description: Some(format!("Test tool: {tool_name}").into()), - input_schema: Arc::new(JsonObject::default()), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - }, - connector_id: connector_id.map(str::to_string), - connector_name: connector_name.map(str::to_string), - plugin_display_names: Vec::new(), - } - } - - fn function_call_rollout_item(name: &str, call_id: &str) -> RolloutItem { - RolloutItem::ResponseItem(ResponseItem::FunctionCall { - id: None, - name: name.to_string(), - arguments: "{}".to_string(), - call_id: call_id.to_string(), - }) - } - - fn function_call_output_rollout_item(call_id: &str, output: &str) -> RolloutItem { - RolloutItem::ResponseItem(ResponseItem::FunctionCallOutput { - call_id: call_id.to_string(), - output: FunctionCallOutputPayload::from_text(output.to_string()), - }) - } - - #[test] - fn validated_network_policy_amendment_host_allows_normalized_match() { - let amendment = NetworkPolicyAmendment { - host: "ExAmPlE.Com.:443".to_string(), - action: NetworkPolicyRuleAction::Allow, - }; - let context = NetworkApprovalContext { - host: "example.com".to_string(), - protocol: NetworkApprovalProtocol::Https, - }; - - let host = Session::validated_network_policy_amendment_host(&amendment, &context) - .expect("normalized hosts should match"); - - assert_eq!(host, "example.com"); - } - - #[test] - fn validated_network_policy_amendment_host_rejects_mismatch() { - let amendment = NetworkPolicyAmendment { - host: "evil.example.com".to_string(), - action: NetworkPolicyRuleAction::Deny, - }; - let context = NetworkApprovalContext { - host: "api.example.com".to_string(), - protocol: NetworkApprovalProtocol::Https, - }; - - let err = Session::validated_network_policy_amendment_host(&amendment, &context) - .expect_err("mismatched hosts should be rejected"); - - let message = err.to_string(); - assert!(message.contains("does not match approved host")); - } - - #[tokio::test] - async fn get_base_instructions_no_user_content() { - let prompt_with_apply_patch_instructions = - include_str!("../prompt_with_apply_patch_instructions.md"); - let models_response: ModelsResponse = - serde_json::from_str(include_str!("../models.json")).expect("valid models.json"); - let model_info_for_slug = |slug: &str, config: &Config| { - let model = models_response - .models - .iter() - .find(|candidate| candidate.slug == slug) - .cloned() - .unwrap_or_else(|| panic!("model slug {slug} is missing from models.json")); - model_info::with_config_overrides(model, config) - }; - let test_cases = vec![ - InstructionsTestCase { - slug: "gpt-5", - expects_apply_patch_instructions: false, - }, - InstructionsTestCase { - slug: "gpt-5.1", - expects_apply_patch_instructions: false, - }, - InstructionsTestCase { - slug: "gpt-5.1-codex", - expects_apply_patch_instructions: false, - }, - InstructionsTestCase { - slug: "gpt-5.1-codex-max", - expects_apply_patch_instructions: false, - }, - ]; - - let (session, _turn_context) = make_session_and_context().await; - let config = test_config(); - - for test_case in test_cases { - let model_info = model_info_for_slug(test_case.slug, &config); - if test_case.expects_apply_patch_instructions { - assert_eq!( - model_info.base_instructions.as_str(), - prompt_with_apply_patch_instructions - ); - } - - { - let mut state = session.state.lock().await; - state.session_configuration.base_instructions = - model_info.base_instructions.clone(); - } - - let base_instructions = session.get_base_instructions().await; - assert_eq!(base_instructions.text, model_info.base_instructions); - } - } - - #[tokio::test] - async fn reload_user_config_layer_updates_effective_apps_config() { - let (session, _turn_context) = make_session_and_context().await; - let codex_home = session.codex_home().await; - std::fs::create_dir_all(&codex_home).expect("create codex home"); - let config_toml_path = codex_home.join(CONFIG_TOML_FILE); - std::fs::write( - &config_toml_path, - "[apps.calendar]\nenabled = false\ndestructive_enabled = false\n", - ) - .expect("write user config"); - - session.reload_user_config_layer().await; - - let config = session.get_config().await; - let apps_toml = config - .config_layer_stack - .effective_config() - .as_table() - .and_then(|table| table.get("apps")) - .cloned() - .expect("apps table"); - let apps = crate::config::types::AppsConfigToml::deserialize(apps_toml) - .expect("deserialize apps config"); - let app = apps - .apps - .get("calendar") - .expect("calendar app config exists"); - - assert!(!app.enabled); - assert_eq!(app.destructive_enabled, Some(false)); - } - - #[test] - fn filter_connectors_for_input_skips_duplicate_slug_mentions() { - let connectors = vec![ - make_connector("one", "Foo Bar"), - make_connector("two", "Foo-Bar"), - ]; - let input = vec![user_message("use $foo-bar")]; - let explicitly_enabled_connectors = HashSet::new(); - let skill_name_counts_lower = HashMap::new(); - - let selected = filter_connectors_for_input( - &connectors, - &input, - &explicitly_enabled_connectors, - &skill_name_counts_lower, - ); - - assert_eq!(selected, Vec::new()); - } - - #[test] - fn filter_connectors_for_input_skips_when_skill_name_conflicts() { - let connectors = vec![make_connector("one", "Todoist")]; - let input = vec![user_message("use $todoist")]; - let explicitly_enabled_connectors = HashSet::new(); - let skill_name_counts_lower = HashMap::from([("todoist".to_string(), 1)]); - - let selected = filter_connectors_for_input( - &connectors, - &input, - &explicitly_enabled_connectors, - &skill_name_counts_lower, - ); - - assert_eq!(selected, Vec::new()); - } - - #[test] - fn filter_connectors_for_input_skips_disabled_connectors() { - let mut connector = make_connector("calendar", "Calendar"); - connector.is_enabled = false; - let input = vec![user_message("use $calendar")]; - let explicitly_enabled_connectors = HashSet::new(); - let selected = filter_connectors_for_input( - &[connector], - &input, - &explicitly_enabled_connectors, - &HashMap::new(), - ); - - assert_eq!(selected, Vec::new()); - } - - #[test] - fn collect_explicit_app_ids_from_skill_items_includes_linked_mentions() { - let connectors = vec![make_connector("calendar", "Calendar")]; - let skill_items = vec![skill_message( - "\ndemo\n/tmp/skills/demo/SKILL.md\nuse [$calendar](app://calendar)\n", - )]; - - let connector_ids = - collect_explicit_app_ids_from_skill_items(&skill_items, &connectors, &HashMap::new()); - - assert_eq!(connector_ids, HashSet::from(["calendar".to_string()])); - } - - #[test] - fn collect_explicit_app_ids_from_skill_items_resolves_unambiguous_plain_mentions() { - let connectors = vec![make_connector("calendar", "Calendar")]; - let skill_items = vec![skill_message( - "\ndemo\n/tmp/skills/demo/SKILL.md\nuse $calendar\n", - )]; - - let connector_ids = - collect_explicit_app_ids_from_skill_items(&skill_items, &connectors, &HashMap::new()); - - assert_eq!(connector_ids, HashSet::from(["calendar".to_string()])); - } - - #[test] - fn collect_explicit_app_ids_from_skill_items_skips_plain_mentions_with_skill_conflicts() { - let connectors = vec![make_connector("calendar", "Calendar")]; - let skill_items = vec![skill_message( - "\ndemo\n/tmp/skills/demo/SKILL.md\nuse $calendar\n", - )]; - let skill_name_counts_lower = HashMap::from([("calendar".to_string(), 1)]); - - let connector_ids = collect_explicit_app_ids_from_skill_items( - &skill_items, - &connectors, - &skill_name_counts_lower, - ); - - assert_eq!(connector_ids, HashSet::::new()); - } - - #[test] - fn non_app_mcp_tools_remain_visible_without_search_selection() { - let mcp_tools = HashMap::from([ - ( - "mcp__codex_apps__calendar_create_event".to_string(), - make_mcp_tool( - CODEX_APPS_MCP_SERVER_NAME, - "calendar_create_event", - Some("calendar"), - Some("Calendar"), - ), - ), - ( - "mcp__rmcp__echo".to_string(), - make_mcp_tool("rmcp", "echo", None, None), - ), - ]); - - let mut selected_mcp_tools = mcp_tools - .iter() - .filter(|(_, tool)| tool.server_name != CODEX_APPS_MCP_SERVER_NAME) - .map(|(name, tool)| (name.clone(), tool.clone())) - .collect::>(); - - let connectors = connectors::accessible_connectors_from_mcp_tools(&mcp_tools); - let explicitly_enabled_connectors = HashSet::new(); - let connectors = filter_connectors_for_input( - &connectors, - &[user_message("run echo")], - &explicitly_enabled_connectors, - &HashMap::new(), - ); - let apps_mcp_tools = filter_codex_apps_mcp_tools_only(&mcp_tools, &connectors); - selected_mcp_tools.extend(apps_mcp_tools); - - let mut tool_names: Vec = selected_mcp_tools.into_keys().collect(); - tool_names.sort(); - assert_eq!(tool_names, vec!["mcp__rmcp__echo".to_string()]); - } - - #[test] - fn search_tool_selection_keeps_codex_apps_tools_without_mentions() { - let selected_tool_names = vec![ - "mcp__codex_apps__calendar_create_event".to_string(), - "mcp__rmcp__echo".to_string(), - ]; - let mcp_tools = HashMap::from([ - ( - "mcp__codex_apps__calendar_create_event".to_string(), - make_mcp_tool( - CODEX_APPS_MCP_SERVER_NAME, - "calendar_create_event", - Some("calendar"), - Some("Calendar"), - ), - ), - ( - "mcp__rmcp__echo".to_string(), - make_mcp_tool("rmcp", "echo", None, None), - ), - ]); - - let mut selected_mcp_tools = filter_mcp_tools_by_name(&mcp_tools, &selected_tool_names); - let connectors = connectors::accessible_connectors_from_mcp_tools(&mcp_tools); - let explicitly_enabled_connectors = HashSet::new(); - let connectors = filter_connectors_for_input( - &connectors, - &[user_message("run the selected tools")], - &explicitly_enabled_connectors, - &HashMap::new(), - ); - let apps_mcp_tools = filter_codex_apps_mcp_tools_only(&mcp_tools, &connectors); - selected_mcp_tools.extend(apps_mcp_tools); - - let mut tool_names: Vec = selected_mcp_tools.into_keys().collect(); - tool_names.sort(); - assert_eq!( - tool_names, - vec![ - "mcp__codex_apps__calendar_create_event".to_string(), - "mcp__rmcp__echo".to_string(), - ] - ); - } - - #[test] - fn apps_mentions_add_codex_apps_tools_to_search_selected_set() { - let selected_tool_names = vec!["mcp__rmcp__echo".to_string()]; - let mcp_tools = HashMap::from([ - ( - "mcp__codex_apps__calendar_create_event".to_string(), - make_mcp_tool( - CODEX_APPS_MCP_SERVER_NAME, - "calendar_create_event", - Some("calendar"), - Some("Calendar"), - ), - ), - ( - "mcp__rmcp__echo".to_string(), - make_mcp_tool("rmcp", "echo", None, None), - ), - ]); - - let mut selected_mcp_tools = filter_mcp_tools_by_name(&mcp_tools, &selected_tool_names); - let connectors = connectors::accessible_connectors_from_mcp_tools(&mcp_tools); - let explicitly_enabled_connectors = HashSet::new(); - let connectors = filter_connectors_for_input( - &connectors, - &[user_message("use $calendar and then echo the response")], - &explicitly_enabled_connectors, - &HashMap::new(), - ); - let apps_mcp_tools = filter_codex_apps_mcp_tools_only(&mcp_tools, &connectors); - selected_mcp_tools.extend(apps_mcp_tools); - - let mut tool_names: Vec = selected_mcp_tools.into_keys().collect(); - tool_names.sort(); - assert_eq!( - tool_names, - vec![ - "mcp__codex_apps__calendar_create_event".to_string(), - "mcp__rmcp__echo".to_string(), - ] - ); - } - - #[test] - fn extract_mcp_tool_selection_from_rollout_reads_search_tool_output() { - let rollout_items = vec![ - function_call_rollout_item(SEARCH_TOOL_BM25_TOOL_NAME, "search-1"), - function_call_output_rollout_item( - "search-1", - &json!({ - "active_selected_tools": [ - "mcp__codex_apps__calendar_create_event", - "mcp__codex_apps__calendar_list_events", - ], - }) - .to_string(), - ), - ]; - - let selected = Session::extract_mcp_tool_selection_from_rollout(&rollout_items); - assert_eq!( - selected, - Some(vec![ - "mcp__codex_apps__calendar_create_event".to_string(), - "mcp__codex_apps__calendar_list_events".to_string(), - ]) - ); - } - - #[test] - fn extract_mcp_tool_selection_from_rollout_latest_valid_payload_wins() { - let rollout_items = vec![ - function_call_rollout_item(SEARCH_TOOL_BM25_TOOL_NAME, "search-1"), - function_call_output_rollout_item( - "search-1", - &json!({ - "active_selected_tools": ["mcp__codex_apps__calendar_create_event"], - }) - .to_string(), - ), - function_call_rollout_item(SEARCH_TOOL_BM25_TOOL_NAME, "search-2"), - function_call_output_rollout_item( - "search-2", - &json!({ - "active_selected_tools": ["mcp__codex_apps__calendar_delete_event"], - }) - .to_string(), - ), - ]; - - let selected = Session::extract_mcp_tool_selection_from_rollout(&rollout_items); - assert_eq!( - selected, - Some(vec!["mcp__codex_apps__calendar_delete_event".to_string(),]) - ); - } - - #[test] - fn extract_mcp_tool_selection_from_rollout_ignores_non_search_and_malformed_payloads() { - let rollout_items = vec![ - function_call_rollout_item("shell", "shell-1"), - function_call_output_rollout_item( - "shell-1", - &json!({ - "active_selected_tools": ["mcp__codex_apps__should_be_ignored"], - }) - .to_string(), - ), - function_call_rollout_item(SEARCH_TOOL_BM25_TOOL_NAME, "search-1"), - function_call_output_rollout_item("search-1", "{not-json"), - function_call_output_rollout_item( - "unknown-search-call", - &json!({ - "active_selected_tools": ["mcp__codex_apps__also_ignored"], - }) - .to_string(), - ), - function_call_output_rollout_item( - "search-1", - &json!({ - "active_selected_tools": ["mcp__codex_apps__calendar_list_events"], - }) - .to_string(), - ), - ]; - - let selected = Session::extract_mcp_tool_selection_from_rollout(&rollout_items); - assert_eq!( - selected, - Some(vec!["mcp__codex_apps__calendar_list_events".to_string(),]) - ); - } - - #[test] - fn extract_mcp_tool_selection_from_rollout_returns_none_without_valid_search_output() { - let rollout_items = vec![function_call_rollout_item( - SEARCH_TOOL_BM25_TOOL_NAME, - "search-1", - )]; - let selected = Session::extract_mcp_tool_selection_from_rollout(&rollout_items); - assert_eq!(selected, None); - } - - #[tokio::test] - async fn reconstruct_history_matches_live_compactions() { - let (session, turn_context) = make_session_and_context().await; - let (rollout_items, expected) = sample_rollout(&session, &turn_context).await; - - let reconstruction_turn = session.new_default_turn().await; - let reconstructed = session - .reconstruct_history_from_rollout(reconstruction_turn.as_ref(), &rollout_items) - .await; - - assert_eq!(expected, reconstructed.history); - } - - #[tokio::test] - async fn reconstruct_history_uses_replacement_history_verbatim() { - let (session, turn_context) = make_session_and_context().await; - let summary_item = ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "summary".to_string(), - }], - end_turn: None, - phase: None, - }; - let replacement_history = vec![ - summary_item.clone(), - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "stale developer instructions".to_string(), - }], - end_turn: None, - phase: None, - }, - ]; - let rollout_items = vec![RolloutItem::Compacted(CompactedItem { - message: String::new(), - replacement_history: Some(replacement_history.clone()), - })]; - - let reconstructed = session - .reconstruct_history_from_rollout(&turn_context, &rollout_items) - .await; - - assert_eq!(reconstructed.history, replacement_history); - } - - #[tokio::test] - async fn record_initial_history_reconstructs_resumed_transcript() { - let (session, turn_context) = make_session_and_context().await; - let (rollout_items, expected) = sample_rollout(&session, &turn_context).await; - - session - .record_initial_history(InitialHistory::Resumed(ResumedHistory { - conversation_id: ThreadId::default(), - history: rollout_items, - rollout_path: PathBuf::from("/tmp/resume.jsonl"), - })) - .await; - - let history = session.state.lock().await.clone_history(); - assert_eq!(expected, history.raw_items()); - } - - #[tokio::test] - async fn resumed_history_injects_initial_context_on_first_context_update_only() { - let (session, turn_context) = make_session_and_context().await; - let (rollout_items, mut expected) = sample_rollout(&session, &turn_context).await; - - session - .record_initial_history(InitialHistory::Resumed(ResumedHistory { - conversation_id: ThreadId::default(), - history: rollout_items, - rollout_path: PathBuf::from("/tmp/resume.jsonl"), - })) - .await; - - let history_before_seed = session.state.lock().await.clone_history(); - assert_eq!(expected, history_before_seed.raw_items()); - - session - .record_context_updates_and_set_reference_context_item(&turn_context) - .await; - expected.extend(session.build_initial_context(&turn_context).await); - let history_after_seed = session.clone_history().await; - assert_eq!(expected, history_after_seed.raw_items()); - - session - .record_context_updates_and_set_reference_context_item(&turn_context) - .await; - let history_after_second_seed = session.clone_history().await; - assert_eq!( - history_after_seed.raw_items(), - history_after_second_seed.raw_items() - ); - } - - #[tokio::test] - async fn record_initial_history_seeds_token_info_from_rollout() { - let (session, turn_context) = make_session_and_context().await; - let (mut rollout_items, _expected) = sample_rollout(&session, &turn_context).await; - - let info1 = TokenUsageInfo { - total_token_usage: TokenUsage { - input_tokens: 10, - cached_input_tokens: 0, - output_tokens: 20, - reasoning_output_tokens: 0, - total_tokens: 30, - }, - last_token_usage: TokenUsage { - input_tokens: 3, - cached_input_tokens: 0, - output_tokens: 4, - reasoning_output_tokens: 0, - total_tokens: 7, - }, - model_context_window: Some(1_000), - }; - let info2 = TokenUsageInfo { - total_token_usage: TokenUsage { - input_tokens: 100, - cached_input_tokens: 50, - output_tokens: 200, - reasoning_output_tokens: 25, - total_tokens: 375, - }, - last_token_usage: TokenUsage { - input_tokens: 10, - cached_input_tokens: 0, - output_tokens: 20, - reasoning_output_tokens: 5, - total_tokens: 35, - }, - model_context_window: Some(2_000), - }; - - rollout_items.push(RolloutItem::EventMsg(EventMsg::TokenCount( - TokenCountEvent { - info: Some(info1), - rate_limits: None, - }, - ))); - rollout_items.push(RolloutItem::EventMsg(EventMsg::TokenCount( - TokenCountEvent { - info: None, - rate_limits: None, - }, - ))); - rollout_items.push(RolloutItem::EventMsg(EventMsg::TokenCount( - TokenCountEvent { - info: Some(info2.clone()), - rate_limits: None, - }, - ))); - rollout_items.push(RolloutItem::EventMsg(EventMsg::TokenCount( - TokenCountEvent { - info: None, - rate_limits: None, - }, - ))); - - session - .record_initial_history(InitialHistory::Resumed(ResumedHistory { - conversation_id: ThreadId::default(), - history: rollout_items, - rollout_path: PathBuf::from("/tmp/resume.jsonl"), - })) - .await; - - let actual = session.state.lock().await.token_info(); - assert_eq!(actual, Some(info2)); - } - - #[tokio::test] - async fn recompute_token_usage_uses_session_base_instructions() { - let (session, turn_context) = make_session_and_context().await; - - let override_instructions = "SESSION_OVERRIDE_INSTRUCTIONS_ONLY".repeat(120); - { - let mut state = session.state.lock().await; - state.session_configuration.base_instructions = override_instructions.clone(); - } - - let item = user_message("hello"); - session - .record_into_history(std::slice::from_ref(&item), &turn_context) - .await; - - let history = session.clone_history().await; - let session_base_instructions = BaseInstructions { - text: override_instructions, - }; - let expected_tokens = history - .estimate_token_count_with_base_instructions(&session_base_instructions) - .expect("estimate with session base instructions"); - let model_estimated_tokens = history - .estimate_token_count(&turn_context) - .expect("estimate with model instructions"); - assert_ne!(expected_tokens, model_estimated_tokens); - - session.recompute_token_usage(&turn_context).await; - - let actual_tokens = session - .state - .lock() - .await - .token_info() - .expect("token info") - .last_token_usage - .total_tokens; - assert_eq!(actual_tokens, expected_tokens.max(0)); - } - - #[tokio::test] - async fn recompute_token_usage_updates_model_context_window() { - let (session, mut turn_context) = make_session_and_context().await; - - { - let mut state = session.state.lock().await; - state.set_token_info(Some(TokenUsageInfo { - total_token_usage: TokenUsage::default(), - last_token_usage: TokenUsage::default(), - model_context_window: Some(258_400), - })); - } - - turn_context.model_info.context_window = Some(128_000); - turn_context.model_info.effective_context_window_percent = 100; - - session.recompute_token_usage(&turn_context).await; - - let actual = session.state.lock().await.token_info().expect("token info"); - assert_eq!(actual.model_context_window, Some(128_000)); - } - - #[tokio::test] - async fn record_initial_history_reconstructs_forked_transcript() { - let (session, turn_context) = make_session_and_context().await; - let (rollout_items, mut expected) = sample_rollout(&session, &turn_context).await; - - session - .record_initial_history(InitialHistory::Forked(rollout_items)) - .await; - - let reconstruction_turn = session.new_default_turn().await; - expected.extend( - session - .build_initial_context(reconstruction_turn.as_ref()) - .await, - ); - let history = session.state.lock().await.clone_history(); - assert_eq!(expected, history.raw_items()); - } - - #[tokio::test] - async fn record_initial_history_forked_hydrates_previous_turn_settings() { - let (session, turn_context) = make_session_and_context().await; - let previous_model = "forked-rollout-model"; - let previous_context_item = TurnContextItem { - turn_id: Some(turn_context.sub_id.clone()), - trace_id: turn_context.trace_id.clone(), - cwd: turn_context.cwd.clone(), - current_date: turn_context.current_date.clone(), - timezone: turn_context.timezone.clone(), - approval_policy: turn_context.approval_policy.value(), - sandbox_policy: turn_context.sandbox_policy.get().clone(), - network: None, - model: previous_model.to_string(), - personality: turn_context.personality, - collaboration_mode: Some(turn_context.collaboration_mode.clone()), - realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort, - summary: turn_context.reasoning_summary, - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy.into()), - }; - let turn_id = previous_context_item - .turn_id - .clone() - .expect("turn context should have turn_id"); - let rollout_items = vec![ - RolloutItem::EventMsg(EventMsg::TurnStarted( - codex_protocol::protocol::TurnStartedEvent { - turn_id: turn_id.clone(), - model_context_window: Some(128_000), - collaboration_mode_kind: ModeKind::Default, - }, - )), - RolloutItem::EventMsg(EventMsg::UserMessage( - codex_protocol::protocol::UserMessageEvent { - message: "forked seed".to_string(), - images: None, - local_images: Vec::new(), - text_elements: Vec::new(), - }, - )), - RolloutItem::TurnContext(previous_context_item), - RolloutItem::EventMsg(EventMsg::TurnComplete( - codex_protocol::protocol::TurnCompleteEvent { - turn_id, - last_agent_message: None, - }, - )), - ]; - - session - .record_initial_history(InitialHistory::Forked(rollout_items)) - .await; - - assert_eq!( - session.previous_turn_settings().await, - Some(PreviousTurnSettings { - model: previous_model.to_string(), - realtime_active: Some(turn_context.realtime_active), - }) - ); - } - - #[tokio::test] - async fn thread_rollback_drops_last_turn_from_history() { - let (sess, tc, rx) = make_session_and_context_with_rx().await; - let rollout_path = attach_rollout_recorder(&sess).await; - - let initial_context = sess.build_initial_context(tc.as_ref()).await; - let turn_1 = vec![ - user_message("turn 1 user"), - assistant_message("turn 1 assistant"), - ]; - let turn_2 = vec![ - user_message("turn 2 user"), - assistant_message("turn 2 assistant"), - ]; - let mut full_history = Vec::new(); - full_history.extend(initial_context.clone()); - full_history.extend(turn_1.clone()); - full_history.extend(turn_2); - sess.replace_history(full_history.clone(), Some(tc.to_turn_context_item())) - .await; - let rollout_items: Vec = full_history - .into_iter() - .map(RolloutItem::ResponseItem) - .collect(); - sess.persist_rollout_items(&rollout_items).await; - sess.set_previous_turn_settings(Some(PreviousTurnSettings { - model: "stale-model".to_string(), - realtime_active: Some(tc.realtime_active), - })) - .await; - { - let mut state = sess.state.lock().await; - state.set_reference_context_item(Some(tc.to_turn_context_item())); - } - - handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; - - let rollback_event = wait_for_thread_rolled_back(&rx).await; - assert_eq!(rollback_event.num_turns, 1); - - let mut expected = Vec::new(); - expected.extend(initial_context); - expected.extend(turn_1); - - let history = sess.clone_history().await; - assert_eq!(expected, history.raw_items()); - assert_eq!(sess.previous_turn_settings().await, None); - assert!(sess.reference_context_item().await.is_none()); - - let InitialHistory::Resumed(resumed) = RolloutRecorder::get_rollout_history(&rollout_path) - .await - .expect("read rollout history") - else { - panic!("expected resumed rollout history"); - }; - assert!(resumed.history.iter().any(|item| { - matches!( - item, - RolloutItem::EventMsg(EventMsg::ThreadRolledBack(rollback)) - if rollback.num_turns == 1 - ) - })); - } - - #[tokio::test] - async fn thread_rollback_clears_history_when_num_turns_exceeds_existing_turns() { - let (sess, tc, rx) = make_session_and_context_with_rx().await; - attach_rollout_recorder(&sess).await; - - let initial_context = sess.build_initial_context(tc.as_ref()).await; - let turn_1 = vec![user_message("turn 1 user")]; - let mut full_history = Vec::new(); - full_history.extend(initial_context.clone()); - full_history.extend(turn_1); - sess.replace_history(full_history.clone(), Some(tc.to_turn_context_item())) - .await; - let rollout_items: Vec = full_history - .into_iter() - .map(RolloutItem::ResponseItem) - .collect(); - sess.persist_rollout_items(&rollout_items).await; - - handlers::thread_rollback(&sess, "sub-1".to_string(), 99).await; - - let rollback_event = wait_for_thread_rolled_back(&rx).await; - assert_eq!(rollback_event.num_turns, 99); - - let history = sess.clone_history().await; - assert_eq!(initial_context, history.raw_items()); - } - - #[tokio::test] - async fn thread_rollback_fails_without_persisted_rollout_path() { - let (sess, tc, rx) = make_session_and_context_with_rx().await; - - let initial_context = sess.build_initial_context(tc.as_ref()).await; - sess.record_into_history(&initial_context, tc.as_ref()) - .await; - - handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; - - let error_event = wait_for_thread_rollback_failed(&rx).await; - assert_eq!( - error_event.message, - "thread rollback requires a persisted rollout path" - ); - assert_eq!( - error_event.codex_error_info, - Some(CodexErrorInfo::ThreadRollbackFailed) - ); - assert_eq!(sess.clone_history().await.raw_items(), initial_context); - } - - #[tokio::test] - async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context_from_replay() { - let (sess, tc, rx) = make_session_and_context_with_rx().await; - attach_rollout_recorder(&sess).await; - - let first_context_item = tc.to_turn_context_item(); - let first_turn_id = first_context_item - .turn_id - .clone() - .expect("turn context should have turn_id"); - let mut rolled_back_context_item = first_context_item.clone(); - rolled_back_context_item.turn_id = Some("rolled-back-turn".to_string()); - rolled_back_context_item.model = "rolled-back-model".to_string(); - let rolled_back_turn_id = rolled_back_context_item - .turn_id - .clone() - .expect("turn context should have turn_id"); - let turn_one_user = user_message("turn 1 user"); - let turn_one_assistant = assistant_message("turn 1 assistant"); - let turn_two_user = user_message("turn 2 user"); - let turn_two_assistant = assistant_message("turn 2 assistant"); - - sess.persist_rollout_items(&[ - RolloutItem::EventMsg(EventMsg::TurnStarted( - codex_protocol::protocol::TurnStartedEvent { - turn_id: first_turn_id.clone(), - model_context_window: Some(128_000), - collaboration_mode_kind: ModeKind::Default, - }, - )), - RolloutItem::EventMsg(EventMsg::UserMessage( - codex_protocol::protocol::UserMessageEvent { - message: "turn 1 user".to_string(), - images: None, - local_images: Vec::new(), - text_elements: Vec::new(), - }, - )), - RolloutItem::TurnContext(first_context_item.clone()), - RolloutItem::ResponseItem(turn_one_user.clone()), - RolloutItem::ResponseItem(turn_one_assistant.clone()), - RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: first_turn_id, - last_agent_message: None, - })), - RolloutItem::EventMsg(EventMsg::TurnStarted( - codex_protocol::protocol::TurnStartedEvent { - turn_id: rolled_back_turn_id.clone(), - model_context_window: Some(128_000), - collaboration_mode_kind: ModeKind::Default, - }, - )), - RolloutItem::EventMsg(EventMsg::UserMessage( - codex_protocol::protocol::UserMessageEvent { - message: "turn 2 user".to_string(), - images: None, - local_images: Vec::new(), - text_elements: Vec::new(), - }, - )), - RolloutItem::TurnContext(rolled_back_context_item), - RolloutItem::ResponseItem(turn_two_user), - RolloutItem::ResponseItem(turn_two_assistant), - RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: rolled_back_turn_id, - last_agent_message: None, - })), - ]) - .await; - sess.replace_history( - vec![assistant_message("stale history")], - Some(first_context_item.clone()), - ) - .await; - sess.set_previous_turn_settings(Some(PreviousTurnSettings { - model: "stale-model".to_string(), - realtime_active: None, - })) - .await; - - handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; - let rollback_event = wait_for_thread_rolled_back(&rx).await; - assert_eq!(rollback_event.num_turns, 1); - - assert_eq!( - sess.clone_history().await.raw_items(), - vec![turn_one_user, turn_one_assistant] - ); - assert_eq!( - sess.previous_turn_settings().await, - Some(PreviousTurnSettings { - model: tc.model_info.slug.clone(), - realtime_active: Some(tc.realtime_active), - }) - ); - assert_eq!( - serde_json::to_value(sess.reference_context_item().await) - .expect("serialize replay reference context item"), - serde_json::to_value(Some(first_context_item)) - .expect("serialize expected reference context item") - ); - } - - #[tokio::test] - async fn thread_rollback_persists_marker_and_replays_cumulatively() { - let (sess, tc, rx) = make_session_and_context_with_rx().await; - let rollout_path = attach_rollout_recorder(&sess).await; - let turn_context_item = tc.to_turn_context_item(); - - sess.persist_rollout_items(&[ - RolloutItem::EventMsg(EventMsg::TurnStarted( - codex_protocol::protocol::TurnStartedEvent { - turn_id: "turn-1".to_string(), - model_context_window: Some(128_000), - collaboration_mode_kind: ModeKind::Default, - }, - )), - RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { - message: "turn 1 user".to_string(), - images: None, - local_images: Vec::new(), - text_elements: Vec::new(), - })), - RolloutItem::TurnContext(turn_context_item.clone()), - RolloutItem::ResponseItem(user_message("turn 1 user")), - RolloutItem::ResponseItem(assistant_message("turn 1 assistant")), - RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: None, - })), - RolloutItem::EventMsg(EventMsg::TurnStarted( - codex_protocol::protocol::TurnStartedEvent { - turn_id: "turn-2".to_string(), - model_context_window: Some(128_000), - collaboration_mode_kind: ModeKind::Default, - }, - )), - RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { - message: "turn 2 user".to_string(), - images: None, - local_images: Vec::new(), - text_elements: Vec::new(), - })), - RolloutItem::TurnContext(turn_context_item.clone()), - RolloutItem::ResponseItem(user_message("turn 2 user")), - RolloutItem::ResponseItem(assistant_message("turn 2 assistant")), - RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-2".to_string(), - last_agent_message: None, - })), - RolloutItem::EventMsg(EventMsg::TurnStarted( - codex_protocol::protocol::TurnStartedEvent { - turn_id: "turn-3".to_string(), - model_context_window: Some(128_000), - collaboration_mode_kind: ModeKind::Default, - }, - )), - RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { - message: "turn 3 user".to_string(), - images: None, - local_images: Vec::new(), - text_elements: Vec::new(), - })), - RolloutItem::TurnContext(turn_context_item), - RolloutItem::ResponseItem(user_message("turn 3 user")), - RolloutItem::ResponseItem(assistant_message("turn 3 assistant")), - RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-3".to_string(), - last_agent_message: None, - })), - ]) - .await; - - handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; - let first_rollback = wait_for_thread_rolled_back(&rx).await; - assert_eq!(first_rollback.num_turns, 1); - handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; - let second_rollback = wait_for_thread_rolled_back(&rx).await; - assert_eq!(second_rollback.num_turns, 1); - - assert_eq!( - sess.clone_history().await.raw_items(), - vec![ - user_message("turn 1 user"), - assistant_message("turn 1 assistant") - ] - ); - - let InitialHistory::Resumed(resumed) = RolloutRecorder::get_rollout_history(&rollout_path) - .await - .expect("read rollout history") - else { - panic!("expected resumed rollout history"); - }; - let rollback_markers = resumed - .history - .iter() - .filter(|item| matches!(item, RolloutItem::EventMsg(EventMsg::ThreadRolledBack(_)))) - .count(); - assert_eq!(rollback_markers, 2); - } - - #[tokio::test] - async fn thread_rollback_fails_when_turn_in_progress() { - let (sess, tc, rx) = make_session_and_context_with_rx().await; - - let initial_context = sess.build_initial_context(tc.as_ref()).await; - sess.record_into_history(&initial_context, tc.as_ref()) - .await; - - *sess.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); - handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; - - let error_event = wait_for_thread_rollback_failed(&rx).await; - assert_eq!( - error_event.codex_error_info, - Some(CodexErrorInfo::ThreadRollbackFailed) - ); - - let history = sess.clone_history().await; - assert_eq!(initial_context, history.raw_items()); - } - - #[tokio::test] - async fn thread_rollback_fails_when_num_turns_is_zero() { - let (sess, tc, rx) = make_session_and_context_with_rx().await; - - let initial_context = sess.build_initial_context(tc.as_ref()).await; - sess.record_into_history(&initial_context, tc.as_ref()) - .await; - - handlers::thread_rollback(&sess, "sub-1".to_string(), 0).await; - - let error_event = wait_for_thread_rollback_failed(&rx).await; - assert_eq!(error_event.message, "num_turns must be >= 1"); - assert_eq!( - error_event.codex_error_info, - Some(CodexErrorInfo::ThreadRollbackFailed) - ); - - let history = sess.clone_history().await; - assert_eq!(initial_context, history.raw_items()); - } - - #[tokio::test] - async fn set_rate_limits_retains_previous_credits() { - let codex_home = tempfile::tempdir().expect("create temp dir"); - let config = build_test_config(codex_home.path()).await; - let config = Arc::new(config); - let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref()); - let model_info = - ModelsManager::construct_model_info_offline_for_tests(model.as_str(), &config); - let reasoning_effort = config.model_reasoning_effort; - let collaboration_mode = CollaborationMode { - mode: ModeKind::Default, - settings: Settings { - model, - reasoning_effort, - developer_instructions: None, - }, - }; - let session_configuration = SessionConfiguration { - provider: config.model_provider.clone(), - collaboration_mode, - model_reasoning_summary: config.model_reasoning_summary, - developer_instructions: config.developer_instructions.clone(), - user_instructions: config.user_instructions.clone(), - service_tier: None, - personality: config.personality, - base_instructions: config - .base_instructions - .clone() - .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), - compact_prompt: config.compact_prompt.clone(), - approval_policy: config.permissions.approval_policy.clone(), - sandbox_policy: config.permissions.sandbox_policy.clone(), - windows_sandbox_level: WindowsSandboxLevel::from_config(&config), - cwd: config.cwd.clone(), - codex_home: config.codex_home.clone(), - thread_name: None, - original_config_do_not_use: Arc::clone(&config), - metrics_service_name: None, - app_server_client_name: None, - session_source: SessionSource::Exec, - dynamic_tools: Vec::new(), - persist_extended_history: false, - inherited_shell_snapshot: None, - }; - - let mut state = SessionState::new(session_configuration); - let initial = RateLimitSnapshot { - limit_id: None, - limit_name: None, - primary: Some(RateLimitWindow { - used_percent: 10.0, - window_minutes: Some(15), - resets_at: Some(1_700), - }), - secondary: None, - credits: Some(CreditsSnapshot { - has_credits: true, - unlimited: false, - balance: Some("10.00".to_string()), - }), - plan_type: Some(codex_protocol::account::PlanType::Plus), - }; - state.set_rate_limits(initial.clone()); - - let update = RateLimitSnapshot { - limit_id: Some("codex_other".to_string()), - limit_name: Some("codex_other".to_string()), - primary: Some(RateLimitWindow { - used_percent: 40.0, - window_minutes: Some(30), - resets_at: Some(1_800), - }), - secondary: Some(RateLimitWindow { - used_percent: 5.0, - window_minutes: Some(60), - resets_at: Some(1_900), - }), - credits: None, - plan_type: None, - }; - state.set_rate_limits(update.clone()); - - assert_eq!( - state.latest_rate_limits, - Some(RateLimitSnapshot { - limit_id: Some("codex_other".to_string()), - limit_name: Some("codex_other".to_string()), - primary: update.primary.clone(), - secondary: update.secondary, - credits: initial.credits, - plan_type: initial.plan_type, - }) - ); - } - - #[tokio::test] - async fn set_rate_limits_updates_plan_type_when_present() { - let codex_home = tempfile::tempdir().expect("create temp dir"); - let config = build_test_config(codex_home.path()).await; - let config = Arc::new(config); - let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref()); - let model_info = - ModelsManager::construct_model_info_offline_for_tests(model.as_str(), &config); - let reasoning_effort = config.model_reasoning_effort; - let collaboration_mode = CollaborationMode { - mode: ModeKind::Default, - settings: Settings { - model, - reasoning_effort, - developer_instructions: None, - }, - }; - let session_configuration = SessionConfiguration { - provider: config.model_provider.clone(), - collaboration_mode, - model_reasoning_summary: config.model_reasoning_summary, - developer_instructions: config.developer_instructions.clone(), - user_instructions: config.user_instructions.clone(), - service_tier: None, - personality: config.personality, - base_instructions: config - .base_instructions - .clone() - .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), - compact_prompt: config.compact_prompt.clone(), - approval_policy: config.permissions.approval_policy.clone(), - sandbox_policy: config.permissions.sandbox_policy.clone(), - windows_sandbox_level: WindowsSandboxLevel::from_config(&config), - cwd: config.cwd.clone(), - codex_home: config.codex_home.clone(), - thread_name: None, - original_config_do_not_use: Arc::clone(&config), - metrics_service_name: None, - app_server_client_name: None, - session_source: SessionSource::Exec, - dynamic_tools: Vec::new(), - persist_extended_history: false, - inherited_shell_snapshot: None, - }; - - let mut state = SessionState::new(session_configuration); - let initial = RateLimitSnapshot { - limit_id: None, - limit_name: None, - primary: Some(RateLimitWindow { - used_percent: 15.0, - window_minutes: Some(20), - resets_at: Some(1_600), - }), - secondary: Some(RateLimitWindow { - used_percent: 5.0, - window_minutes: Some(45), - resets_at: Some(1_650), - }), - credits: Some(CreditsSnapshot { - has_credits: true, - unlimited: false, - balance: Some("15.00".to_string()), - }), - plan_type: Some(codex_protocol::account::PlanType::Plus), - }; - state.set_rate_limits(initial.clone()); - - let update = RateLimitSnapshot { - limit_id: None, - limit_name: None, - primary: Some(RateLimitWindow { - used_percent: 35.0, - window_minutes: Some(25), - resets_at: Some(1_700), - }), - secondary: None, - credits: None, - plan_type: Some(codex_protocol::account::PlanType::Pro), - }; - state.set_rate_limits(update.clone()); - - assert_eq!( - state.latest_rate_limits, - Some(RateLimitSnapshot { - limit_id: Some("codex".to_string()), - limit_name: None, - primary: update.primary, - secondary: update.secondary, - credits: initial.credits, - plan_type: update.plan_type, - }) - ); - } - - #[test] - fn prefers_structured_content_when_present() { - let ctr = McpCallToolResult { - // Content present but should be ignored because structured_content is set. - content: vec![text_block("ignored")], - is_error: None, - structured_content: Some(json!({ - "ok": true, - "value": 42 - })), - meta: None, - }; - - let got = FunctionCallOutputPayload::from(&ctr); - let expected = FunctionCallOutputPayload { - body: FunctionCallOutputBody::Text( - serde_json::to_string(&json!({ - "ok": true, - "value": 42 - })) - .unwrap(), - ), - success: Some(true), - }; - - assert_eq!(expected, got); - } - - #[tokio::test] - async fn includes_timed_out_message() { - let exec = ExecToolCallOutput { - exit_code: 0, - stdout: StreamOutput::new(String::new()), - stderr: StreamOutput::new(String::new()), - aggregated_output: StreamOutput::new("Command output".to_string()), - duration: StdDuration::from_secs(1), - timed_out: true, - }; - let (_, turn_context) = make_session_and_context().await; - - let out = format_exec_output_str(&exec, turn_context.truncation_policy); - - assert_eq!( - out, - "command timed out after 1000 milliseconds\nCommand output" - ); - } - - #[tokio::test] - async fn turn_context_with_model_updates_model_fields() { - let (session, mut turn_context) = make_session_and_context().await; - turn_context.reasoning_effort = Some(ReasoningEffortConfig::Minimal); - let updated = turn_context - .with_model("gpt-5.1".to_string(), &session.services.models_manager) - .await; - let expected_model_info = session - .services - .models_manager - .get_model_info("gpt-5.1", updated.config.as_ref()) - .await; - - assert_eq!(updated.config.model.as_deref(), Some("gpt-5.1")); - assert_eq!(updated.collaboration_mode.model(), "gpt-5.1"); - assert_eq!(updated.model_info, expected_model_info); - assert_eq!( - updated.reasoning_effort, - Some(ReasoningEffortConfig::Medium) - ); - assert_eq!( - updated.collaboration_mode.reasoning_effort(), - Some(ReasoningEffortConfig::Medium) - ); - assert_eq!( - updated.config.model_reasoning_effort, - Some(ReasoningEffortConfig::Medium) - ); - assert_eq!( - updated.truncation_policy, - expected_model_info.truncation_policy.into() - ); - assert!(!Arc::ptr_eq( - &updated.tool_call_gate, - &turn_context.tool_call_gate - )); - } - - #[test] - fn falls_back_to_content_when_structured_is_null() { - let ctr = McpCallToolResult { - content: vec![text_block("hello"), text_block("world")], - is_error: None, - structured_content: Some(serde_json::Value::Null), - meta: None, - }; - - let got = FunctionCallOutputPayload::from(&ctr); - let expected = FunctionCallOutputPayload { - body: FunctionCallOutputBody::Text( - serde_json::to_string(&vec![text_block("hello"), text_block("world")]).unwrap(), - ), - success: Some(true), - }; - - assert_eq!(expected, got); - } - - #[test] - fn success_flag_reflects_is_error_true() { - let ctr = McpCallToolResult { - content: vec![text_block("unused")], - is_error: Some(true), - structured_content: Some(json!({ "message": "bad" })), - meta: None, - }; - - let got = FunctionCallOutputPayload::from(&ctr); - let expected = FunctionCallOutputPayload { - body: FunctionCallOutputBody::Text( - serde_json::to_string(&json!({ "message": "bad" })).unwrap(), - ), - success: Some(false), - }; - - assert_eq!(expected, got); - } - - #[test] - fn success_flag_true_with_no_error_and_content_used() { - let ctr = McpCallToolResult { - content: vec![text_block("alpha")], - is_error: Some(false), - structured_content: None, - meta: None, - }; - - let got = FunctionCallOutputPayload::from(&ctr); - let expected = FunctionCallOutputPayload { - body: FunctionCallOutputBody::Text( - serde_json::to_string(&vec![text_block("alpha")]).unwrap(), - ), - success: Some(true), - }; - - assert_eq!(expected, got); - } - - async fn wait_for_thread_rolled_back( - rx: &async_channel::Receiver, - ) -> crate::protocol::ThreadRolledBackEvent { - let deadline = StdDuration::from_secs(2); - let start = std::time::Instant::now(); - loop { - let remaining = deadline.saturating_sub(start.elapsed()); - let evt = tokio::time::timeout(remaining, rx.recv()) - .await - .expect("timeout waiting for event") - .expect("event"); - match evt.msg { - EventMsg::ThreadRolledBack(payload) => return payload, - _ => continue, - } - } - } - - async fn wait_for_thread_rollback_failed(rx: &async_channel::Receiver) -> ErrorEvent { - let deadline = StdDuration::from_secs(2); - let start = std::time::Instant::now(); - loop { - let remaining = deadline.saturating_sub(start.elapsed()); - let evt = tokio::time::timeout(remaining, rx.recv()) - .await - .expect("timeout waiting for event") - .expect("event"); - match evt.msg { - EventMsg::Error(payload) - if payload.codex_error_info == Some(CodexErrorInfo::ThreadRollbackFailed) => - { - return payload; - } - _ => continue, - } - } - } - - async fn attach_rollout_recorder(session: &Arc) -> PathBuf { - let config = session.get_config().await; - let recorder = RolloutRecorder::new( - config.as_ref(), - RolloutRecorderParams::new( - ThreadId::default(), - None, - SessionSource::Exec, - BaseInstructions::default(), - Vec::new(), - EventPersistenceMode::Limited, - ), - None, - None, - ) - .await - .expect("create rollout recorder"); - let rollout_path = recorder.rollout_path().to_path_buf(); - { - let mut rollout = session.services.rollout.lock().await; - *rollout = Some(recorder); - } - session.ensure_rollout_materialized().await; - session.flush_rollout().await; - rollout_path - } - - fn text_block(s: &str) -> serde_json::Value { - json!({ - "type": "text", - "text": s, - }) - } - - fn init_test_tracing() { - static INIT: Once = Once::new(); - INIT.call_once(|| { - let provider = SdkTracerProvider::builder().build(); - let tracer = provider.tracer("codex-core-tests"); - let subscriber = tracing_subscriber::registry() - .with(tracing_opentelemetry::layer().with_tracer(tracer)); - tracing::subscriber::set_global_default(subscriber) - .expect("global tracing subscriber should only be installed once"); - }); - } - - async fn build_test_config(codex_home: &Path) -> Config { - ConfigBuilder::default() - .codex_home(codex_home.to_path_buf()) - .build() - .await - .expect("load default test config") - } - - fn otel_manager( - conversation_id: ThreadId, - config: &Config, - model_info: &ModelInfo, - session_source: SessionSource, - ) -> OtelManager { - OtelManager::new( - conversation_id, - ModelsManager::get_model_offline_for_tests(config.model.as_deref()).as_str(), - model_info.slug.as_str(), - None, - Some("test@test.com".to_string()), - Some(TelemetryAuthMode::Chatgpt), - "test_originator".to_string(), - false, - "test".to_string(), - session_source, - ) - } - - pub(crate) async fn make_session_configuration_for_tests() -> SessionConfiguration { - let codex_home = tempfile::tempdir().expect("create temp dir"); - let config = build_test_config(codex_home.path()).await; - let config = Arc::new(config); - let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref()); - let model_info = - ModelsManager::construct_model_info_offline_for_tests(model.as_str(), &config); - let reasoning_effort = config.model_reasoning_effort; - let collaboration_mode = CollaborationMode { - mode: ModeKind::Default, - settings: Settings { - model, - reasoning_effort, - developer_instructions: None, - }, - }; - - SessionConfiguration { - provider: config.model_provider.clone(), - collaboration_mode, - model_reasoning_summary: config.model_reasoning_summary, - developer_instructions: config.developer_instructions.clone(), - user_instructions: config.user_instructions.clone(), - service_tier: None, - personality: config.personality, - base_instructions: config - .base_instructions - .clone() - .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), - compact_prompt: config.compact_prompt.clone(), - approval_policy: config.permissions.approval_policy.clone(), - sandbox_policy: config.permissions.sandbox_policy.clone(), - windows_sandbox_level: WindowsSandboxLevel::from_config(&config), - cwd: config.cwd.clone(), - codex_home: config.codex_home.clone(), - thread_name: None, - original_config_do_not_use: Arc::clone(&config), - metrics_service_name: None, - app_server_client_name: None, - session_source: SessionSource::Exec, - dynamic_tools: Vec::new(), - persist_extended_history: false, - inherited_shell_snapshot: None, - } - } - - #[tokio::test] - async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { - let codex_home = tempfile::tempdir().expect("create temp dir"); - let mut config = build_test_config(codex_home.path()).await; - config - .features - .enable(Feature::ShellZshFork) - .expect("test config should allow shell_zsh_fork"); - config.zsh_path = None; - let config = Arc::new(config); - - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = Arc::new(ModelsManager::new( - config.codex_home.clone(), - auth_manager.clone(), - None, - CollaborationModesConfig::default(), - )); - let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref()); - let model_info = - ModelsManager::construct_model_info_offline_for_tests(model.as_str(), &config); - let collaboration_mode = CollaborationMode { - mode: ModeKind::Default, - settings: Settings { - model, - reasoning_effort: config.model_reasoning_effort, - developer_instructions: None, - }, - }; - let session_configuration = SessionConfiguration { - provider: config.model_provider.clone(), - collaboration_mode, - model_reasoning_summary: config.model_reasoning_summary, - developer_instructions: config.developer_instructions.clone(), - user_instructions: config.user_instructions.clone(), - service_tier: None, - personality: config.personality, - base_instructions: config - .base_instructions - .clone() - .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), - compact_prompt: config.compact_prompt.clone(), - approval_policy: config.permissions.approval_policy.clone(), - sandbox_policy: config.permissions.sandbox_policy.clone(), - windows_sandbox_level: WindowsSandboxLevel::from_config(&config), - cwd: config.cwd.clone(), - codex_home: config.codex_home.clone(), - thread_name: None, - original_config_do_not_use: Arc::clone(&config), - metrics_service_name: None, - app_server_client_name: None, - session_source: SessionSource::Exec, - dynamic_tools: Vec::new(), - persist_extended_history: false, - inherited_shell_snapshot: None, - }; - - let (tx_event, _rx_event) = async_channel::unbounded(); - let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit); - let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone())); - let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); - let skills_manager = Arc::new(SkillsManager::new( - config.codex_home.clone(), - Arc::clone(&plugins_manager), - )); - let result = Session::new( - session_configuration, - Arc::clone(&config), - auth_manager, - models_manager, - ExecPolicyManager::default(), - tx_event, - agent_status_tx, - InitialHistory::New, - SessionSource::Exec, - skills_manager, - plugins_manager, - mcp_manager, - Arc::new(FileWatcher::noop()), - AgentControl::default(), - ) - .await; - - let err = match result { - Ok(_) => panic!("expected startup to fail"), - Err(err) => err, - }; - let msg = format!("{err:#}"); - assert!(msg.contains("zsh fork feature enabled, but `zsh_path` is not configured")); - } - - // todo: use online model info - pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { - let (tx_event, _rx_event) = async_channel::unbounded(); - let codex_home = tempfile::tempdir().expect("create temp dir"); - let config = build_test_config(codex_home.path()).await; - let config = Arc::new(config); - let conversation_id = ThreadId::default(); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = Arc::new(ModelsManager::new( - config.codex_home.clone(), - auth_manager.clone(), - None, - CollaborationModesConfig::default(), - )); - let agent_control = AgentControl::default(); - let exec_policy = ExecPolicyManager::default(); - let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit); - let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref()); - let model_info = - ModelsManager::construct_model_info_offline_for_tests(model.as_str(), &config); - let reasoning_effort = config.model_reasoning_effort; - let collaboration_mode = CollaborationMode { - mode: ModeKind::Default, - settings: Settings { - model, - reasoning_effort, - developer_instructions: None, - }, - }; - let session_configuration = SessionConfiguration { - provider: config.model_provider.clone(), - collaboration_mode, - model_reasoning_summary: config.model_reasoning_summary, - developer_instructions: config.developer_instructions.clone(), - user_instructions: config.user_instructions.clone(), - service_tier: None, - personality: config.personality, - base_instructions: config - .base_instructions - .clone() - .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), - compact_prompt: config.compact_prompt.clone(), - approval_policy: config.permissions.approval_policy.clone(), - sandbox_policy: config.permissions.sandbox_policy.clone(), - windows_sandbox_level: WindowsSandboxLevel::from_config(&config), - cwd: config.cwd.clone(), - codex_home: config.codex_home.clone(), - thread_name: None, - original_config_do_not_use: Arc::clone(&config), - metrics_service_name: None, - app_server_client_name: None, - session_source: SessionSource::Exec, - dynamic_tools: Vec::new(), - persist_extended_history: false, - inherited_shell_snapshot: None, - }; - let per_turn_config = Session::build_per_turn_config(&session_configuration); - let model_info = ModelsManager::construct_model_info_offline_for_tests( - session_configuration.collaboration_mode.model(), - &per_turn_config, - ); - let otel_manager = otel_manager( - conversation_id, - config.as_ref(), - &model_info, - session_configuration.session_source.clone(), - ); - - let state = SessionState::new(session_configuration.clone()); - let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone())); - let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); - let skills_manager = Arc::new(SkillsManager::new( - config.codex_home.clone(), - Arc::clone(&plugins_manager), - )); - let network_approval = Arc::new(NetworkApprovalService::default()); - - let file_watcher = Arc::new(FileWatcher::noop()); - let services = SessionServices { - mcp_connection_manager: Arc::new(RwLock::new( - McpConnectionManager::new_mcp_connection_manager_for_tests( - &config.permissions.approval_policy, - ), - )), - mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()), - unified_exec_manager: UnifiedExecProcessManager::new( - config.background_terminal_max_timeout, - ), - shell_zsh_path: None, - main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&config), - Arc::clone(&auth_manager), - ), - hooks: Hooks::new(HooksConfig { - legacy_notify_argv: config.notify.clone(), - }), - rollout: Mutex::new(None), - user_shell: Arc::new(default_user_shell()), - shell_snapshot_tx: watch::channel(None).0, - show_raw_agent_reasoning: config.show_raw_agent_reasoning, - exec_policy, - auth_manager: auth_manager.clone(), - otel_manager: otel_manager.clone(), - models_manager: Arc::clone(&models_manager), - tool_approvals: Mutex::new(ApprovalStore::default()), - execve_session_approvals: RwLock::new(HashMap::new()), - skills_manager, - plugins_manager, - mcp_manager, - file_watcher, - agent_control, - network_proxy: None, - network_approval: Arc::clone(&network_approval), - state_db: None, - model_client: ModelClient::new( - Some(auth_manager.clone()), - conversation_id, - session_configuration.provider.clone(), - session_configuration.session_source.clone(), - config.model_verbosity, - ws_version_from_features(config.as_ref()), - config.features.enabled(Feature::EnableRequestCompression), - config.features.enabled(Feature::RuntimeMetrics), - Session::build_model_client_beta_features_header(config.as_ref()), - ), - }; - let js_repl = Arc::new(JsReplHandle::with_node_path( - config.js_repl_node_path.clone(), - config.js_repl_node_module_dirs.clone(), - )); - - let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&per_turn_config)); - let turn_context = Session::make_turn_context( - Some(Arc::clone(&auth_manager)), - &otel_manager, - session_configuration.provider.clone(), - &session_configuration, - per_turn_config, - model_info, - None, - "turn_id".to_string(), - Arc::clone(&js_repl), - skills_outcome, - ); - - let session = Session { - conversation_id, - tx_event, - agent_status: agent_status_tx, - state: Mutex::new(state), - features: config.features.clone(), - pending_mcp_server_refresh_config: Mutex::new(None), - conversation: Arc::new(RealtimeConversationManager::new()), - active_turn: Mutex::new(None), - services, - js_repl, - next_internal_sub_id: AtomicU64::new(0), - }; - - (session, turn_context) - } - - #[tokio::test] - async fn submit_with_id_captures_current_span_trace_context() { - let (session, _turn_context) = make_session_and_context().await; - let (tx_sub, rx_sub) = async_channel::bounded(1); - let (_tx_event, rx_event) = async_channel::unbounded(); - let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit); - let codex = Codex { - tx_sub, - rx_event, - agent_status, - session: Arc::new(session), - }; - - init_test_tracing(); - - let request_parent = W3cTraceContext { - traceparent: Some("00-00000000000000000000000000000011-0000000000000022-01".into()), - tracestate: Some("vendor=value".into()), - }; - let request_span = info_span!("app_server.request"); - assert!(set_parent_from_w3c_trace_context( - &request_span, - &request_parent - )); - - let expected_trace = async { - let expected_trace = - current_span_w3c_trace_context().expect("current span should have trace context"); - codex - .submit_with_id(Submission { - id: "sub-1".into(), - op: Op::Interrupt, - trace: None, - }) - .await - .expect("submit should succeed"); - expected_trace - } - .instrument(request_span) - .await; - - let submitted = rx_sub.recv().await.expect("submission"); - assert_eq!(submitted.trace, Some(expected_trace)); - } - - #[tokio::test] - async fn new_default_turn_captures_current_span_trace_id() { - let (session, _turn_context) = make_session_and_context().await; - - init_test_tracing(); - - let request_parent = W3cTraceContext { - traceparent: Some("00-00000000000000000000000000000011-0000000000000022-01".into()), - tracestate: Some("vendor=value".into()), - }; - let request_span = info_span!("app_server.request"); - assert!(set_parent_from_w3c_trace_context( - &request_span, - &request_parent - )); - - let turn_context_item = async { - let expected_trace_id = Span::current() - .context() - .span() - .span_context() - .trace_id() - .to_string(); - let turn_context = session.new_default_turn().await; - let turn_context_item = turn_context.to_turn_context_item(); - assert_eq!(turn_context_item.trace_id, Some(expected_trace_id)); - turn_context_item - } - .instrument(request_span) - .await; - - assert_eq!( - turn_context_item.trace_id.as_deref(), - Some("00000000000000000000000000000011") - ); - } - - #[test] - fn submission_dispatch_span_prefers_submission_trace_context() { - init_test_tracing(); - - let ambient_parent = W3cTraceContext { - traceparent: Some("00-00000000000000000000000000000033-0000000000000044-01".into()), - tracestate: None, - }; - let ambient_span = info_span!("ambient"); - assert!(set_parent_from_w3c_trace_context( - &ambient_span, - &ambient_parent - )); - - let submission_trace = W3cTraceContext { - traceparent: Some("00-00000000000000000000000000000055-0000000000000066-01".into()), - tracestate: Some("vendor=value".into()), - }; - let dispatch_span = ambient_span.in_scope(|| { - submission_dispatch_span(&Submission { - id: "sub-1".into(), - op: Op::Interrupt, - trace: Some(submission_trace), - }) - }); - - let trace_id = dispatch_span.context().span().span_context().trace_id(); - assert_eq!( - trace_id, - TraceId::from_hex("00000000000000000000000000000055").expect("trace id") - ); - } - - #[test] - fn submission_dispatch_span_uses_debug_for_realtime_audio() { - init_test_tracing(); - - let dispatch_span = submission_dispatch_span(&Submission { - id: "sub-1".into(), - op: Op::RealtimeConversationAudio(ConversationAudioParams { - frame: RealtimeAudioFrame { - data: "ZmFrZQ==".into(), - sample_rate: 16_000, - num_channels: 1, - samples_per_channel: Some(160), - }, - }), - trace: None, - }); - - assert_eq!( - dispatch_span.metadata().expect("span metadata").level(), - &tracing::Level::DEBUG - ); - } - - #[tokio::test] - async fn spawn_task_turn_span_inherits_dispatch_trace_context() { - struct TraceCaptureTask { - captured_trace: Arc>>, - } - - #[async_trait::async_trait] - impl SessionTask for TraceCaptureTask { - fn kind(&self) -> TaskKind { - TaskKind::Regular - } - - fn span_name(&self) -> &'static str { - "session_task.trace_capture" - } - - async fn run( - self: Arc, - _session: Arc, - _ctx: Arc, - _input: Vec, - _cancellation_token: CancellationToken, - ) -> Option { - let mut trace = self - .captured_trace - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - *trace = current_span_w3c_trace_context(); - None - } - } - - init_test_tracing(); - - let request_parent = W3cTraceContext { - traceparent: Some("00-00000000000000000000000000000011-0000000000000022-01".into()), - tracestate: Some("vendor=value".into()), - }; - let request_span = tracing::info_span!("app_server.request"); - assert!(set_parent_from_w3c_trace_context( - &request_span, - &request_parent - )); - - let submission_trace = async { - current_span_w3c_trace_context().expect("request span should have trace context") - } - .instrument(request_span) - .await; - - let dispatch_span = submission_dispatch_span(&Submission { - id: "sub-1".into(), - op: Op::Interrupt, - trace: Some(submission_trace.clone()), - }); - let dispatch_span_id = dispatch_span.context().span().span_context().span_id(); - - let (sess, tc, rx) = make_session_and_context_with_rx().await; - let captured_trace = Arc::new(std::sync::Mutex::new(None)); - - async { - sess.spawn_task( - Arc::clone(&tc), - vec![UserInput::Text { - text: "hello".to_string(), - text_elements: Vec::new(), - }], - TraceCaptureTask { - captured_trace: Arc::clone(&captured_trace), - }, - ) - .await; - } - .instrument(dispatch_span) - .await; - - let evt = tokio::time::timeout(StdDuration::from_secs(2), rx.recv()) - .await - .expect("timeout waiting for turn completion") - .expect("event"); - assert!(matches!(evt.msg, EventMsg::TurnComplete(_))); - - let task_trace = captured_trace - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clone() - .expect("turn task should capture the current span trace context"); - let submission_context = - codex_otel::context_from_w3c_trace_context(&submission_trace).expect("submission"); - let task_context = - codex_otel::context_from_w3c_trace_context(&task_trace).expect("task trace"); - - assert_eq!( - task_context.span().span_context().trace_id(), - submission_context.span().span_context().trace_id() - ); - assert_ne!( - task_context.span().span_context().span_id(), - dispatch_span_id - ); - } - - pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( - dynamic_tools: Vec, - ) -> ( - Arc, - Arc, - async_channel::Receiver, - ) { - let (tx_event, rx_event) = async_channel::unbounded(); - let codex_home = tempfile::tempdir().expect("create temp dir"); - let config = build_test_config(codex_home.path()).await; - let config = Arc::new(config); - let conversation_id = ThreadId::default(); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = Arc::new(ModelsManager::new( - config.codex_home.clone(), - auth_manager.clone(), - None, - CollaborationModesConfig::default(), - )); - let agent_control = AgentControl::default(); - let exec_policy = ExecPolicyManager::default(); - let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit); - let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref()); - let model_info = - ModelsManager::construct_model_info_offline_for_tests(model.as_str(), &config); - let reasoning_effort = config.model_reasoning_effort; - let collaboration_mode = CollaborationMode { - mode: ModeKind::Default, - settings: Settings { - model, - reasoning_effort, - developer_instructions: None, - }, - }; - let session_configuration = SessionConfiguration { - provider: config.model_provider.clone(), - collaboration_mode, - model_reasoning_summary: config.model_reasoning_summary, - developer_instructions: config.developer_instructions.clone(), - user_instructions: config.user_instructions.clone(), - service_tier: None, - personality: config.personality, - base_instructions: config - .base_instructions - .clone() - .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), - compact_prompt: config.compact_prompt.clone(), - approval_policy: config.permissions.approval_policy.clone(), - sandbox_policy: config.permissions.sandbox_policy.clone(), - windows_sandbox_level: WindowsSandboxLevel::from_config(&config), - cwd: config.cwd.clone(), - codex_home: config.codex_home.clone(), - thread_name: None, - original_config_do_not_use: Arc::clone(&config), - metrics_service_name: None, - app_server_client_name: None, - session_source: SessionSource::Exec, - dynamic_tools, - persist_extended_history: false, - inherited_shell_snapshot: None, - }; - let per_turn_config = Session::build_per_turn_config(&session_configuration); - let model_info = ModelsManager::construct_model_info_offline_for_tests( - session_configuration.collaboration_mode.model(), - &per_turn_config, - ); - let otel_manager = otel_manager( - conversation_id, - config.as_ref(), - &model_info, - session_configuration.session_source.clone(), - ); - - let state = SessionState::new(session_configuration.clone()); - let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone())); - let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); - let skills_manager = Arc::new(SkillsManager::new( - config.codex_home.clone(), - Arc::clone(&plugins_manager), - )); - let network_approval = Arc::new(NetworkApprovalService::default()); - - let file_watcher = Arc::new(FileWatcher::noop()); - let services = SessionServices { - mcp_connection_manager: Arc::new(RwLock::new( - McpConnectionManager::new_mcp_connection_manager_for_tests( - &config.permissions.approval_policy, - ), - )), - mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()), - unified_exec_manager: UnifiedExecProcessManager::new( - config.background_terminal_max_timeout, - ), - shell_zsh_path: None, - main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&config), - Arc::clone(&auth_manager), - ), - hooks: Hooks::new(HooksConfig { - legacy_notify_argv: config.notify.clone(), - }), - rollout: Mutex::new(None), - user_shell: Arc::new(default_user_shell()), - shell_snapshot_tx: watch::channel(None).0, - show_raw_agent_reasoning: config.show_raw_agent_reasoning, - exec_policy, - auth_manager: Arc::clone(&auth_manager), - otel_manager: otel_manager.clone(), - models_manager: Arc::clone(&models_manager), - tool_approvals: Mutex::new(ApprovalStore::default()), - execve_session_approvals: RwLock::new(HashMap::new()), - skills_manager, - plugins_manager, - mcp_manager, - file_watcher, - agent_control, - network_proxy: None, - network_approval: Arc::clone(&network_approval), - state_db: None, - model_client: ModelClient::new( - Some(Arc::clone(&auth_manager)), - conversation_id, - session_configuration.provider.clone(), - session_configuration.session_source.clone(), - config.model_verbosity, - ws_version_from_features(config.as_ref()), - config.features.enabled(Feature::EnableRequestCompression), - config.features.enabled(Feature::RuntimeMetrics), - Session::build_model_client_beta_features_header(config.as_ref()), - ), - }; - let js_repl = Arc::new(JsReplHandle::with_node_path( - config.js_repl_node_path.clone(), - config.js_repl_node_module_dirs.clone(), - )); - - let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&per_turn_config)); - let turn_context = Arc::new(Session::make_turn_context( - Some(Arc::clone(&auth_manager)), - &otel_manager, - session_configuration.provider.clone(), - &session_configuration, - per_turn_config, - model_info, - None, - "turn_id".to_string(), - Arc::clone(&js_repl), - skills_outcome, - )); - - let session = Arc::new(Session { - conversation_id, - tx_event, - agent_status: agent_status_tx, - state: Mutex::new(state), - features: config.features.clone(), - pending_mcp_server_refresh_config: Mutex::new(None), - conversation: Arc::new(RealtimeConversationManager::new()), - active_turn: Mutex::new(None), - services, - js_repl, - next_internal_sub_id: AtomicU64::new(0), - }); - - (session, turn_context, rx_event) - } - - // Like make_session_and_context, but returns Arc and the event receiver - // so tests can assert on emitted events. - pub(crate) async fn make_session_and_context_with_rx() -> ( - Arc, - Arc, - async_channel::Receiver, - ) { - make_session_and_context_with_dynamic_tools_and_rx(Vec::new()).await - } - - #[tokio::test] - async fn refresh_mcp_servers_is_deferred_until_next_turn() { - let (session, turn_context) = make_session_and_context().await; - let old_token = session.mcp_startup_cancellation_token().await; - assert!(!old_token.is_cancelled()); - - let mcp_oauth_credentials_store_mode = - serde_json::to_value(OAuthCredentialsStoreMode::Auto).expect("serialize store mode"); - let refresh_config = McpServerRefreshConfig { - mcp_servers: json!({}), - mcp_oauth_credentials_store_mode, - }; - { - let mut guard = session.pending_mcp_server_refresh_config.lock().await; - *guard = Some(refresh_config); - } - - assert!(!old_token.is_cancelled()); - assert!( - session - .pending_mcp_server_refresh_config - .lock() - .await - .is_some() - ); - - session - .refresh_mcp_servers_if_requested(&turn_context) - .await; - - assert!(old_token.is_cancelled()); - assert!( - session - .pending_mcp_server_refresh_config - .lock() - .await - .is_none() - ); - let new_token = session.mcp_startup_cancellation_token().await; - assert!(!new_token.is_cancelled()); - } - - #[tokio::test] - async fn record_model_warning_appends_user_message() { - let (mut session, turn_context) = make_session_and_context().await; - let features = crate::features::Features::with_defaults().into(); - session.features = features; - - session - .record_model_warning("too many unified exec processes", &turn_context) - .await; - - let history = session.clone_history().await; - let history_items = history.raw_items(); - let last = history_items.last().expect("warning recorded"); - - match last { - ResponseItem::Message { role, content, .. } => { - assert_eq!(role, "user"); - assert_eq!( - content, - &vec![ContentItem::InputText { - text: "Warning: too many unified exec processes".to_string(), - }] - ); - } - other => panic!("expected user message, got {other:?}"), - } - } - - #[tokio::test] - async fn spawn_task_does_not_update_previous_turn_settings_for_non_run_turn_tasks() { - let (sess, tc, _rx) = make_session_and_context_with_rx().await; - sess.set_previous_turn_settings(None).await; - let input = vec![UserInput::Text { - text: "hello".to_string(), - text_elements: Vec::new(), - }]; - - sess.spawn_task( - Arc::clone(&tc), - input, - NeverEndingTask { - kind: TaskKind::Regular, - listen_to_cancellation_token: true, - }, - ) - .await; - - sess.abort_all_tasks(TurnAbortReason::Interrupted).await; - assert_eq!(sess.previous_turn_settings().await, None); - } - - #[tokio::test] - async fn build_settings_update_items_emits_environment_item_for_network_changes() { - let (session, previous_context) = make_session_and_context().await; - let previous_context = Arc::new(previous_context); - let mut current_context = previous_context - .with_model( - previous_context.model_info.slug.clone(), - &session.services.models_manager, - ) - .await; - - let mut config = (*current_context.config).clone(); - let mut requirements = config.config_layer_stack.requirements().clone(); - requirements.network = Some(Sourced::new( - NetworkConstraints { - allowed_domains: Some(vec!["api.example.com".to_string()]), - denied_domains: Some(vec!["blocked.example.com".to_string()]), - ..Default::default() - }, - RequirementSource::CloudRequirements, - )); - let layers = config - .config_layer_stack - .get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true) - .into_iter() - .cloned() - .collect(); - config.config_layer_stack = ConfigLayerStack::new( - layers, - requirements, - config.config_layer_stack.requirements_toml().clone(), - ) - .expect("rebuild config layer stack with network requirements"); - current_context.config = Arc::new(config); - - let reference_context_item = previous_context.to_turn_context_item(); - let update_items = session - .build_settings_update_items(Some(&reference_context_item), ¤t_context) - .await; - - let environment_update = update_items - .iter() - .find_map(|item| match item { - ResponseItem::Message { role, content, .. } if role == "user" => { - let [ContentItem::InputText { text }] = content.as_slice() else { - return None; - }; - text.contains("").then_some(text) - } - _ => None, - }) - .expect("environment update item should be emitted"); - assert!(environment_update.contains("")); - assert!(environment_update.contains("api.example.com")); - assert!(environment_update.contains("blocked.example.com")); - } - - #[tokio::test] - async fn build_settings_update_items_emits_environment_item_for_time_changes() { - let (session, previous_context) = make_session_and_context().await; - let previous_context = Arc::new(previous_context); - let mut current_context = previous_context - .with_model( - previous_context.model_info.slug.clone(), - &session.services.models_manager, - ) - .await; - current_context.current_date = Some("2026-02-27".to_string()); - current_context.timezone = Some("Europe/Berlin".to_string()); - - let reference_context_item = previous_context.to_turn_context_item(); - let update_items = session - .build_settings_update_items(Some(&reference_context_item), ¤t_context) - .await; - - let environment_update = update_items - .iter() - .find_map(|item| match item { - ResponseItem::Message { role, content, .. } if role == "user" => { - let [ContentItem::InputText { text }] = content.as_slice() else { - return None; - }; - text.contains("").then_some(text) - } - _ => None, - }) - .expect("environment update item should be emitted"); - assert!(environment_update.contains("2026-02-27")); - assert!(environment_update.contains("Europe/Berlin")); - } - - #[tokio::test] - async fn build_settings_update_items_emits_realtime_start_when_session_becomes_live() { - let (session, previous_context) = make_session_and_context().await; - let previous_context = Arc::new(previous_context); - let mut current_context = previous_context - .with_model( - previous_context.model_info.slug.clone(), - &session.services.models_manager, - ) - .await; - current_context.realtime_active = true; - - let update_items = session - .build_settings_update_items( - Some(&previous_context.to_turn_context_item()), - ¤t_context, - ) - .await; - - let developer_texts = developer_input_texts(&update_items); - assert!( - developer_texts - .iter() - .any(|text| text.contains("")), - "expected a realtime start update, got {developer_texts:?}" - ); - } - - #[tokio::test] - async fn build_settings_update_items_emits_realtime_end_when_session_stops_being_live() { - let (session, mut previous_context) = make_session_and_context().await; - previous_context.realtime_active = true; - let mut current_context = previous_context - .with_model( - previous_context.model_info.slug.clone(), - &session.services.models_manager, - ) - .await; - current_context.realtime_active = false; - - let update_items = session - .build_settings_update_items( - Some(&previous_context.to_turn_context_item()), - ¤t_context, - ) - .await; - - let developer_texts = developer_input_texts(&update_items); - assert!( - developer_texts - .iter() - .any(|text| text.contains("Reason: inactive")), - "expected a realtime end update, got {developer_texts:?}" - ); - } - - #[tokio::test] - async fn build_settings_update_items_uses_previous_turn_settings_for_realtime_end() { - let (session, previous_context) = make_session_and_context().await; - let mut previous_context_item = previous_context.to_turn_context_item(); - previous_context_item.realtime_active = None; - let previous_turn_settings = PreviousTurnSettings { - model: previous_context.model_info.slug.clone(), - realtime_active: Some(true), - }; - let mut current_context = previous_context - .with_model( - previous_context.model_info.slug.clone(), - &session.services.models_manager, - ) - .await; - current_context.realtime_active = false; - - session - .set_previous_turn_settings(Some(previous_turn_settings)) - .await; - let update_items = session - .build_settings_update_items(Some(&previous_context_item), ¤t_context) - .await; - - let developer_texts = developer_input_texts(&update_items); - assert!( - developer_texts - .iter() - .any(|text| text.contains("Reason: inactive")), - "expected a realtime end update from previous turn settings, got {developer_texts:?}" - ); - } - - #[tokio::test] - async fn build_initial_context_uses_previous_realtime_state() { - let (session, mut turn_context) = make_session_and_context().await; - turn_context.realtime_active = true; - - let initial_context = session.build_initial_context(&turn_context).await; - let developer_texts = developer_input_texts(&initial_context); - assert!( - developer_texts - .iter() - .any(|text| text.contains("")), - "expected initial context to describe active realtime state, got {developer_texts:?}" - ); - - let previous_context_item = turn_context.to_turn_context_item(); - { - let mut state = session.state.lock().await; - state.set_reference_context_item(Some(previous_context_item)); - } - let resumed_context = session.build_initial_context(&turn_context).await; - let resumed_developer_texts = developer_input_texts(&resumed_context); - assert!( - !resumed_developer_texts - .iter() - .any(|text| text.contains("")), - "did not expect a duplicate realtime update, got {resumed_developer_texts:?}" - ); - } - - #[tokio::test] - async fn build_initial_context_uses_previous_turn_settings_for_realtime_end() { - let (session, turn_context) = make_session_and_context().await; - let previous_turn_settings = PreviousTurnSettings { - model: turn_context.model_info.slug.clone(), - realtime_active: Some(true), - }; - - session - .set_previous_turn_settings(Some(previous_turn_settings)) - .await; - let initial_context = session.build_initial_context(&turn_context).await; - let developer_texts = developer_input_texts(&initial_context); - assert!( - developer_texts - .iter() - .any(|text| text.contains("Reason: inactive")), - "expected initial context to describe an ended realtime session, got {developer_texts:?}" - ); - } - - #[tokio::test] - async fn build_initial_context_restates_realtime_start_when_reference_context_is_missing() { - let (session, mut turn_context) = make_session_and_context().await; - turn_context.realtime_active = true; - let previous_turn_settings = PreviousTurnSettings { - model: turn_context.model_info.slug.clone(), - realtime_active: Some(true), - }; - - session - .set_previous_turn_settings(Some(previous_turn_settings)) - .await; - let initial_context = session.build_initial_context(&turn_context).await; - let developer_texts = developer_input_texts(&initial_context); - assert!( - developer_texts - .iter() - .any(|text| text.contains("")), - "expected initial context to restate active realtime when the reference context is missing, got {developer_texts:?}" - ); - } - - #[tokio::test] - async fn record_context_updates_and_set_reference_context_item_injects_full_context_when_baseline_missing() - { - let (session, turn_context) = make_session_and_context().await; - session - .record_context_updates_and_set_reference_context_item(&turn_context) - .await; - let history = session.clone_history().await; - let initial_context = session.build_initial_context(&turn_context).await; - assert_eq!(history.raw_items().to_vec(), initial_context); - - let current_context = session.reference_context_item().await; - assert_eq!( - serde_json::to_value(current_context).expect("serialize current context item"), - serde_json::to_value(Some(turn_context.to_turn_context_item())) - .expect("serialize expected context item") - ); - } - - #[tokio::test] - async fn record_context_updates_and_set_reference_context_item_reinjects_full_context_after_clear() - { - let (session, turn_context) = make_session_and_context().await; - let compacted_summary = ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: format!("{}\nsummary", crate::compact::SUMMARY_PREFIX), - }], - end_turn: None, - phase: None, - }; - session - .record_into_history(std::slice::from_ref(&compacted_summary), &turn_context) - .await; - session - .record_context_updates_and_set_reference_context_item(&turn_context) - .await; - { - let mut state = session.state.lock().await; - state.set_reference_context_item(None); - } - session - .replace_history(vec![compacted_summary.clone()], None) - .await; - - session - .record_context_updates_and_set_reference_context_item(&turn_context) - .await; - - let history = session.clone_history().await; - let mut expected_history = vec![compacted_summary]; - expected_history.extend(session.build_initial_context(&turn_context).await); - assert_eq!(history.raw_items().to_vec(), expected_history); - } - - #[tokio::test] - async fn record_context_updates_and_set_reference_context_item_persists_baseline_without_emitting_diffs() - { - let (session, previous_context) = make_session_and_context().await; - let next_model = if previous_context.model_info.slug == "gpt-5.1" { - "gpt-5" - } else { - "gpt-5.1" - }; - let turn_context = previous_context - .with_model(next_model.to_string(), &session.services.models_manager) - .await; - let previous_context_item = previous_context.to_turn_context_item(); - { - let mut state = session.state.lock().await; - state.set_reference_context_item(Some(previous_context_item.clone())); - } - let config = session.get_config().await; - let recorder = RolloutRecorder::new( - config.as_ref(), - RolloutRecorderParams::new( - ThreadId::default(), - None, - SessionSource::Exec, - BaseInstructions::default(), - Vec::new(), - EventPersistenceMode::Limited, - ), - None, - None, - ) - .await - .expect("create rollout recorder"); - let rollout_path = recorder.rollout_path().to_path_buf(); - { - let mut rollout = session.services.rollout.lock().await; - *rollout = Some(recorder); - } - - let update_items = session - .build_settings_update_items(Some(&previous_context_item), &turn_context) - .await; - assert_eq!(update_items, Vec::new()); - - session - .record_context_updates_and_set_reference_context_item(&turn_context) - .await; - - assert_eq!( - session.clone_history().await.raw_items().to_vec(), - Vec::new() - ); - assert_eq!( - serde_json::to_value(session.reference_context_item().await) - .expect("serialize current context item"), - serde_json::to_value(Some(turn_context.to_turn_context_item())) - .expect("serialize expected context item") - ); - session.ensure_rollout_materialized().await; - session.flush_rollout().await; - - let InitialHistory::Resumed(resumed) = RolloutRecorder::get_rollout_history(&rollout_path) - .await - .expect("read rollout history") - else { - panic!("expected resumed rollout history"); - }; - let persisted_turn_context = resumed.history.iter().find_map(|item| match item { - RolloutItem::TurnContext(ctx) => Some(ctx.clone()), - _ => None, - }); - assert_eq!( - serde_json::to_value(persisted_turn_context) - .expect("serialize persisted turn context item"), - serde_json::to_value(Some(turn_context.to_turn_context_item())) - .expect("serialize expected turn context item") - ); - } - - #[tokio::test] - async fn build_initial_context_prepends_model_switch_message() { - let (session, turn_context) = make_session_and_context().await; - let previous_turn_settings = PreviousTurnSettings { - model: "previous-regular-model".to_string(), - realtime_active: None, - }; - - session - .set_previous_turn_settings(Some(previous_turn_settings)) - .await; - let initial_context = session.build_initial_context(&turn_context).await; - - let ResponseItem::Message { role, content, .. } = &initial_context[0] else { - panic!("expected developer message"); - }; - assert_eq!(role, "developer"); - let [ContentItem::InputText { text }, ..] = content.as_slice() else { - panic!("expected developer text"); - }; - assert!(text.contains("")); - } - - #[tokio::test] - async fn record_context_updates_and_set_reference_context_item_persists_full_reinjection_to_rollout() - { - let (session, previous_context) = make_session_and_context().await; - let next_model = if previous_context.model_info.slug == "gpt-5.1" { - "gpt-5" - } else { - "gpt-5.1" - }; - let turn_context = previous_context - .with_model(next_model.to_string(), &session.services.models_manager) - .await; - let config = session.get_config().await; - let recorder = RolloutRecorder::new( - config.as_ref(), - RolloutRecorderParams::new( - ThreadId::default(), - None, - SessionSource::Exec, - BaseInstructions::default(), - Vec::new(), - EventPersistenceMode::Limited, - ), - None, - None, - ) - .await - .expect("create rollout recorder"); - let rollout_path = recorder.rollout_path().to_path_buf(); - { - let mut rollout = session.services.rollout.lock().await; - *rollout = Some(recorder); - } - - session - .persist_rollout_items(&[RolloutItem::EventMsg(EventMsg::UserMessage( - UserMessageEvent { - message: "seed rollout".to_string(), - images: None, - local_images: Vec::new(), - text_elements: Vec::new(), - }, - ))]) - .await; - { - let mut state = session.state.lock().await; - state.set_reference_context_item(None); - } - - session - .set_previous_turn_settings(Some(PreviousTurnSettings { - model: previous_context.model_info.slug.clone(), - realtime_active: Some(previous_context.realtime_active), - })) - .await; - session - .record_context_updates_and_set_reference_context_item(&turn_context) - .await; - session.ensure_rollout_materialized().await; - session.flush_rollout().await; - - let InitialHistory::Resumed(resumed) = RolloutRecorder::get_rollout_history(&rollout_path) - .await - .expect("read rollout history") - else { - panic!("expected resumed rollout history"); - }; - let persisted_turn_context = resumed.history.iter().find_map(|item| match item { - RolloutItem::TurnContext(ctx) => Some(ctx.clone()), - _ => None, - }); - - assert_eq!( - serde_json::to_value(persisted_turn_context) - .expect("serialize persisted turn context item"), - serde_json::to_value(Some(turn_context.to_turn_context_item())) - .expect("serialize expected turn context item") - ); - } - - #[tokio::test] - async fn run_user_shell_command_does_not_set_reference_context_item() { - let (session, _turn_context, rx) = make_session_and_context_with_rx().await; - { - let mut state = session.state.lock().await; - state.set_reference_context_item(None); - } - - handlers::run_user_shell_command(&session, "sub-id".to_string(), "echo shell".to_string()) - .await; - - let deadline = StdDuration::from_secs(15); - let start = std::time::Instant::now(); - loop { - let remaining = deadline.saturating_sub(start.elapsed()); - let evt = tokio::time::timeout(remaining, rx.recv()) - .await - .expect("timeout waiting for event") - .expect("event"); - if matches!(evt.msg, EventMsg::TurnComplete(_)) { - break; - } - } - - assert!( - session.reference_context_item().await.is_none(), - "standalone shell tasks should not mutate previous context" - ); - } - - #[derive(Clone, Copy)] - struct NeverEndingTask { - kind: TaskKind, - listen_to_cancellation_token: bool, - } - - #[async_trait::async_trait] - impl SessionTask for NeverEndingTask { - fn kind(&self) -> TaskKind { - self.kind - } - - fn span_name(&self) -> &'static str { - "session_task.never_ending" - } - - async fn run( - self: Arc, - _session: Arc, - _ctx: Arc, - _input: Vec, - cancellation_token: CancellationToken, - ) -> Option { - if self.listen_to_cancellation_token { - cancellation_token.cancelled().await; - return None; - } - loop { - sleep(Duration::from_secs(60)).await; - } - } - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - #[test_log::test] - async fn abort_regular_task_emits_turn_aborted_only() { - let (sess, tc, rx) = make_session_and_context_with_rx().await; - let input = vec![UserInput::Text { - text: "hello".to_string(), - text_elements: Vec::new(), - }]; - sess.spawn_task( - Arc::clone(&tc), - input, - NeverEndingTask { - kind: TaskKind::Regular, - listen_to_cancellation_token: false, - }, - ) - .await; - - sess.abort_all_tasks(TurnAbortReason::Interrupted).await; - - // Interrupts persist a model-visible `` marker into history, but there is no - // separate client-visible event for that marker (only `EventMsg::TurnAborted`). - let evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) - .await - .expect("timeout waiting for event") - .expect("event"); - match evt.msg { - EventMsg::TurnAborted(e) => assert_eq!(TurnAbortReason::Interrupted, e.reason), - other => panic!("unexpected event: {other:?}"), - } - // No extra events should be emitted after an abort. - assert!(rx.try_recv().is_err()); - } - - #[tokio::test] - async fn abort_gracefully_emits_turn_aborted_only() { - let (sess, tc, rx) = make_session_and_context_with_rx().await; - let input = vec![UserInput::Text { - text: "hello".to_string(), - text_elements: Vec::new(), - }]; - sess.spawn_task( - Arc::clone(&tc), - input, - NeverEndingTask { - kind: TaskKind::Regular, - listen_to_cancellation_token: true, - }, - ) - .await; - - sess.abort_all_tasks(TurnAbortReason::Interrupted).await; - - // Even if tasks handle cancellation gracefully, interrupts still result in `TurnAborted` - // being the only client-visible signal. - let evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) - .await - .expect("timeout waiting for event") - .expect("event"); - match evt.msg { - EventMsg::TurnAborted(e) => assert_eq!(TurnAbortReason::Interrupted, e.reason), - other => panic!("unexpected event: {other:?}"), - } - // No extra events should be emitted after an abort. - assert!(rx.try_recv().is_err()); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() { - let (sess, tc, rx) = make_session_and_context_with_rx().await; - let input = vec![UserInput::Text { - text: "hello".to_string(), - text_elements: Vec::new(), - }]; - sess.spawn_task( - Arc::clone(&tc), - input, - NeverEndingTask { - kind: TaskKind::Regular, - listen_to_cancellation_token: false, - }, - ) - .await; - - while rx.try_recv().is_ok() {} - - sess.inject_response_items(vec![ResponseInputItem::Message { - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "late pending input".to_string(), - }], - }]) - .await - .expect("inject pending input into active turn"); - - sess.on_task_finished(Arc::clone(&tc), None).await; - - let history = sess.clone_history().await; - let expected = ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "late pending input".to_string(), - }], - end_turn: None, - phase: None, - }; - assert!( - history.raw_items().iter().any(|item| item == &expected), - "expected pending input to be persisted into history on turn completion" - ); - - let first = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) - .await - .expect("expected raw response item event") - .expect("channel open"); - assert!(matches!(first.msg, EventMsg::RawResponseItem(_))); - - let second = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) - .await - .expect("expected item started event") - .expect("channel open"); - assert!(matches!( - second.msg, - EventMsg::ItemStarted(ItemStartedEvent { - item: TurnItem::UserMessage(UserMessageItem { content, .. }), - .. - }) if content == vec![UserInput::Text { - text: "late pending input".to_string(), - text_elements: Vec::new(), - }] - )); - - let third = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) - .await - .expect("expected item completed event") - .expect("channel open"); - assert!(matches!( - third.msg, - EventMsg::ItemCompleted(ItemCompletedEvent { - item: TurnItem::UserMessage(UserMessageItem { content, .. }), - .. - }) if content == vec![UserInput::Text { - text: "late pending input".to_string(), - text_elements: Vec::new(), - }] - )); - - let fourth = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) - .await - .expect("expected legacy user message event") - .expect("channel open"); - assert!(matches!( - fourth.msg, - EventMsg::UserMessage(UserMessageEvent { - message, - images, - text_elements, - local_images, - }) if message == "late pending input" - && images == Some(Vec::new()) - && text_elements.is_empty() - && local_images.is_empty() - )); - - let fifth = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) - .await - .expect("expected turn complete event") - .expect("channel open"); - assert!(matches!( - fifth.msg, - EventMsg::TurnComplete(TurnCompleteEvent { - turn_id, - last_agent_message: None, - }) if turn_id == tc.sub_id - )); - } - - #[tokio::test] - async fn steer_input_requires_active_turn() { - let (sess, _tc, _rx) = make_session_and_context_with_rx().await; - let input = vec![UserInput::Text { - text: "steer".to_string(), - text_elements: Vec::new(), - }]; - - let err = sess - .steer_input(input, None) - .await - .expect_err("steering without active turn should fail"); - - assert!(matches!(err, SteerInputError::NoActiveTurn(_))); - } - - #[tokio::test] - async fn steer_input_enforces_expected_turn_id() { - let (sess, tc, _rx) = make_session_and_context_with_rx().await; - let input = vec![UserInput::Text { - text: "hello".to_string(), - text_elements: Vec::new(), - }]; - sess.spawn_task( - Arc::clone(&tc), - input, - NeverEndingTask { - kind: TaskKind::Regular, - listen_to_cancellation_token: false, - }, - ) - .await; - - let steer_input = vec![UserInput::Text { - text: "steer".to_string(), - text_elements: Vec::new(), - }]; - let err = sess - .steer_input(steer_input, Some("different-turn-id")) - .await - .expect_err("mismatched expected turn id should fail"); - - match err { - SteerInputError::ExpectedTurnMismatch { expected, actual } => { - assert_eq!( - (expected, actual), - ("different-turn-id".to_string(), tc.sub_id.clone()) - ); - } - other => panic!("unexpected error: {other:?}"), - } - } - - #[tokio::test] - async fn steer_input_returns_active_turn_id() { - let (sess, tc, _rx) = make_session_and_context_with_rx().await; - let input = vec![UserInput::Text { - text: "hello".to_string(), - text_elements: Vec::new(), - }]; - sess.spawn_task( - Arc::clone(&tc), - input, - NeverEndingTask { - kind: TaskKind::Regular, - listen_to_cancellation_token: false, - }, - ) - .await; - - let steer_input = vec![UserInput::Text { - text: "steer".to_string(), - text_elements: Vec::new(), - }]; - let turn_id = sess - .steer_input(steer_input, Some(&tc.sub_id)) - .await - .expect("steering with matching expected turn id should succeed"); - - assert_eq!(turn_id, tc.sub_id); - assert!(sess.has_pending_input().await); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn abort_review_task_emits_exited_then_aborted_and_records_history() { - let (sess, tc, rx) = make_session_and_context_with_rx().await; - let input = vec![UserInput::Text { - text: "start review".to_string(), - text_elements: Vec::new(), - }]; - sess.spawn_task(Arc::clone(&tc), input, ReviewTask::new()) - .await; - - sess.abort_all_tasks(TurnAbortReason::Interrupted).await; - - // Aborting a review task should exit review mode before surfacing the abort to the client. - // We scan for these events (rather than relying on fixed ordering) since unrelated events - // may interleave. - let mut exited_review_mode_idx = None; - let mut turn_aborted_idx = None; - let mut idx = 0usize; - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(3); - while tokio::time::Instant::now() < deadline { - let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); - let evt = tokio::time::timeout(remaining, rx.recv()) - .await - .expect("timeout waiting for event") - .expect("event"); - let event_idx = idx; - idx = idx.saturating_add(1); - match evt.msg { - EventMsg::ExitedReviewMode(ev) => { - assert!(ev.review_output.is_none()); - exited_review_mode_idx = Some(event_idx); - } - EventMsg::TurnAborted(ev) => { - assert_eq!(TurnAbortReason::Interrupted, ev.reason); - turn_aborted_idx = Some(event_idx); - break; - } - _ => {} - } - } - assert!( - exited_review_mode_idx.is_some(), - "expected ExitedReviewMode after abort" - ); - assert!( - turn_aborted_idx.is_some(), - "expected TurnAborted after abort" - ); - assert!( - exited_review_mode_idx.unwrap() < turn_aborted_idx.unwrap(), - "expected ExitedReviewMode before TurnAborted" - ); - - let history = sess.clone_history().await; - // The `` marker is silent in the event stream, so verify it is still - // recorded in history for the model. - assert!( - history.raw_items().iter().any(|item| { - let ResponseItem::Message { role, content, .. } = item else { - return false; - }; - if role != "user" { - return false; - } - content.iter().any(|content_item| { - let ContentItem::InputText { text } = content_item else { - return false; - }; - text.contains(crate::contextual_user_message::TURN_ABORTED_OPEN_TAG) - }) - }), - "expected a model-visible turn aborted marker in history after interrupt" - ); - } - - #[tokio::test] - async fn fatal_tool_error_stops_turn_and_reports_error() { - let (session, turn_context, _rx) = make_session_and_context_with_rx().await; - let tools = { - session - .services - .mcp_connection_manager - .read() - .await - .list_all_tools() - .await - }; - let app_tools = Some(tools.clone()); - let router = ToolRouter::from_config( - &turn_context.tools_config, - Some( - tools - .into_iter() - .map(|(name, tool)| (name, tool.tool)) - .collect(), - ), - app_tools, - turn_context.dynamic_tools.as_slice(), - ); - let item = ResponseItem::CustomToolCall { - id: None, - status: None, - call_id: "call-1".to_string(), - name: "shell".to_string(), - input: "{}".to_string(), - }; - - let call = ToolRouter::build_tool_call(session.as_ref(), item.clone()) - .await - .expect("build tool call") - .expect("tool call present"); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - let err = router - .dispatch_tool_call( - Arc::clone(&session), - Arc::clone(&turn_context), - tracker, - call, - ToolCallSource::Direct, - ) - .await - .expect_err("expected fatal error"); - - match err { - FunctionCallError::Fatal(message) => { - assert_eq!(message, "tool shell invoked with incompatible payload"); - } - other => panic!("expected FunctionCallError::Fatal, got {other:?}"), - } - } - - async fn sample_rollout( - session: &Session, - _turn_context: &TurnContext, - ) -> (Vec, Vec) { - let mut rollout_items = Vec::new(); - let mut live_history = ContextManager::new(); - - // Use the same turn_context source as record_initial_history so model_info (and thus - // personality_spec) matches reconstruction. - let reconstruction_turn = session.new_default_turn().await; - let mut initial_context = session - .build_initial_context(reconstruction_turn.as_ref()) - .await; - // Ensure personality_spec is present when Personality is enabled, so expected matches - // what reconstruction produces (build_initial_context may omit it when baked into model). - if !initial_context.iter().any(|m| { - matches!(m, ResponseItem::Message { role, content, .. } - if role == "developer" - && content.iter().any(|c| { - matches!(c, ContentItem::InputText { text } if text.contains("")) - })) - }) - && let Some(p) = reconstruction_turn.personality - && session.features.enabled(Feature::Personality) - && let Some(personality_message) = reconstruction_turn - .model_info - .model_messages - .as_ref() - .and_then(|m| m.get_personality_message(Some(p)).filter(|s| !s.is_empty())) - { - let msg = - DeveloperInstructions::personality_spec_message(personality_message).into(); - let insert_at = initial_context - .iter() - .position(|m| matches!(m, ResponseItem::Message { role, .. } if role == "developer")) - .map(|i| i + 1) - .unwrap_or(0); - initial_context.insert(insert_at, msg); - } - for item in &initial_context { - rollout_items.push(RolloutItem::ResponseItem(item.clone())); - } - live_history.record_items( - initial_context.iter(), - reconstruction_turn.truncation_policy, - ); - - let user1 = ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "first user".to_string(), - }], - end_turn: None, - phase: None, - }; - live_history.record_items( - std::iter::once(&user1), - reconstruction_turn.truncation_policy, - ); - rollout_items.push(RolloutItem::ResponseItem(user1.clone())); - - let assistant1 = ResponseItem::Message { - id: None, - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: "assistant reply one".to_string(), - }], - end_turn: None, - phase: None, - }; - live_history.record_items( - std::iter::once(&assistant1), - reconstruction_turn.truncation_policy, - ); - rollout_items.push(RolloutItem::ResponseItem(assistant1.clone())); - - let summary1 = "summary one"; - let snapshot1 = live_history - .clone() - .for_prompt(&reconstruction_turn.model_info.input_modalities); - let user_messages1 = collect_user_messages(&snapshot1); - let rebuilt1 = compact::build_compacted_history(Vec::new(), &user_messages1, summary1); - live_history.replace(rebuilt1); - rollout_items.push(RolloutItem::Compacted(CompactedItem { - message: summary1.to_string(), - replacement_history: None, - })); - - let user2 = ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "second user".to_string(), - }], - end_turn: None, - phase: None, - }; - live_history.record_items( - std::iter::once(&user2), - reconstruction_turn.truncation_policy, - ); - rollout_items.push(RolloutItem::ResponseItem(user2.clone())); - - let assistant2 = ResponseItem::Message { - id: None, - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: "assistant reply two".to_string(), - }], - end_turn: None, - phase: None, - }; - live_history.record_items( - std::iter::once(&assistant2), - reconstruction_turn.truncation_policy, - ); - rollout_items.push(RolloutItem::ResponseItem(assistant2.clone())); - - let summary2 = "summary two"; - let snapshot2 = live_history - .clone() - .for_prompt(&reconstruction_turn.model_info.input_modalities); - let user_messages2 = collect_user_messages(&snapshot2); - let rebuilt2 = compact::build_compacted_history(Vec::new(), &user_messages2, summary2); - live_history.replace(rebuilt2); - rollout_items.push(RolloutItem::Compacted(CompactedItem { - message: summary2.to_string(), - replacement_history: None, - })); - - let user3 = ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "third user".to_string(), - }], - end_turn: None, - phase: None, - }; - live_history.record_items( - std::iter::once(&user3), - reconstruction_turn.truncation_policy, - ); - rollout_items.push(RolloutItem::ResponseItem(user3)); - - let assistant3 = ResponseItem::Message { - id: None, - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: "assistant reply three".to_string(), - }], - end_turn: None, - phase: None, - }; - live_history.record_items( - std::iter::once(&assistant3), - reconstruction_turn.truncation_policy, - ); - rollout_items.push(RolloutItem::ResponseItem(assistant3)); - - ( - rollout_items, - live_history.for_prompt(&reconstruction_turn.model_info.input_modalities), - ) - } - - #[tokio::test] - async fn rejects_escalated_permissions_when_policy_not_on_request() { - use crate::exec::ExecParams; - use crate::protocol::AskForApproval; - use crate::protocol::SandboxPolicy; - use crate::sandboxing::SandboxPermissions; - use crate::turn_diff_tracker::TurnDiffTracker; - use std::collections::HashMap; - - let (session, mut turn_context_raw) = make_session_and_context().await; - // Ensure policy is NOT OnRequest so the early rejection path triggers - turn_context_raw - .approval_policy - .set(AskForApproval::OnFailure) - .expect("test setup should allow updating approval policy"); - let session = Arc::new(session); - let mut turn_context = Arc::new(turn_context_raw); - - let timeout_ms = 1000; - let sandbox_permissions = SandboxPermissions::RequireEscalated; - let params = ExecParams { - command: if cfg!(windows) { - vec![ - "cmd.exe".to_string(), - "/C".to_string(), - "echo hi".to_string(), - ] - } else { - vec![ - "/bin/sh".to_string(), - "-c".to_string(), - "echo hi".to_string(), - ] - }, - cwd: turn_context.cwd.clone(), - expiration: timeout_ms.into(), - env: HashMap::new(), - network: None, - sandbox_permissions, - windows_sandbox_level: turn_context.windows_sandbox_level, - justification: Some("test".to_string()), - arg0: None, - }; - - let params2 = ExecParams { - sandbox_permissions: SandboxPermissions::UseDefault, - command: params.command.clone(), - cwd: params.cwd.clone(), - expiration: timeout_ms.into(), - env: HashMap::new(), - network: None, - windows_sandbox_level: turn_context.windows_sandbox_level, - justification: params.justification.clone(), - arg0: None, - }; - - let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - - let tool_name = "shell"; - let call_id = "test-call".to_string(); - - let handler = ShellHandler; - let resp = handler - .handle(ToolInvocation { - session: Arc::clone(&session), - turn: Arc::clone(&turn_context), - tracker: Arc::clone(&turn_diff_tracker), - call_id, - tool_name: tool_name.to_string(), - payload: ToolPayload::Function { - arguments: serde_json::json!({ - "command": params.command.clone(), - "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), - "timeout_ms": params.expiration.timeout_ms(), - "sandbox_permissions": params.sandbox_permissions, - "justification": params.justification.clone(), - }) - .to_string(), - }, - }) - .await; - - let Err(FunctionCallError::RespondToModel(output)) = resp else { - panic!("expected error result"); - }; - - let expected = format!( - "approval policy is {policy:?}; reject command — you should not ask for escalated permissions if the approval policy is {policy:?}", - policy = turn_context.approval_policy.value() - ); - - pretty_assertions::assert_eq!(output, expected); - - // Now retry the same command WITHOUT escalated permissions; should succeed. - // Force DangerFullAccess to avoid platform sandbox dependencies in tests. - Arc::get_mut(&mut turn_context) - .expect("unique turn context Arc") - .sandbox_policy - .set(SandboxPolicy::DangerFullAccess) - .expect("test setup should allow updating sandbox policy"); - - let resp2 = handler - .handle(ToolInvocation { - session: Arc::clone(&session), - turn: Arc::clone(&turn_context), - tracker: Arc::clone(&turn_diff_tracker), - call_id: "test-call-2".to_string(), - tool_name: tool_name.to_string(), - payload: ToolPayload::Function { - arguments: serde_json::json!({ - "command": params2.command.clone(), - "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), - "timeout_ms": params2.expiration.timeout_ms(), - "sandbox_permissions": params2.sandbox_permissions, - "justification": params2.justification.clone(), - }) - .to_string(), - }, - }) - .await; - - let output = match resp2.expect("expected Ok result") { - ToolOutput::Function { - body: FunctionCallOutputBody::Text(content), - .. - } => content, - _ => panic!("unexpected tool output"), - }; - - #[derive(Deserialize, PartialEq, Eq, Debug)] - struct ResponseExecMetadata { - exit_code: i32, - } - - #[derive(Deserialize)] - struct ResponseExecOutput { - output: String, - metadata: ResponseExecMetadata, - } - - let exec_output: ResponseExecOutput = - serde_json::from_str(&output).expect("valid exec output json"); - - pretty_assertions::assert_eq!(exec_output.metadata, ResponseExecMetadata { exit_code: 0 }); - assert!(exec_output.output.contains("hi")); - } - #[tokio::test] - async fn unified_exec_rejects_escalated_permissions_when_policy_not_on_request() { - use crate::protocol::AskForApproval; - use crate::sandboxing::SandboxPermissions; - use crate::turn_diff_tracker::TurnDiffTracker; - - let (session, mut turn_context_raw) = make_session_and_context().await; - turn_context_raw - .approval_policy - .set(AskForApproval::OnFailure) - .expect("test setup should allow updating approval policy"); - let session = Arc::new(session); - let turn_context = Arc::new(turn_context_raw); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - - let handler = UnifiedExecHandler; - let resp = handler - .handle(ToolInvocation { - session: Arc::clone(&session), - turn: Arc::clone(&turn_context), - tracker: Arc::clone(&tracker), - call_id: "exec-call".to_string(), - tool_name: "exec_command".to_string(), - payload: ToolPayload::Function { - arguments: serde_json::json!({ - "cmd": "echo hi", - "sandbox_permissions": SandboxPermissions::RequireEscalated, - "justification": "need unsandboxed execution", - }) - .to_string(), - }, - }) - .await; - - let Err(FunctionCallError::RespondToModel(output)) = resp else { - panic!("expected error result"); - }; - - let expected = format!( - "approval policy is {policy:?}; reject command — you cannot ask for escalated permissions if the approval policy is {policy:?}", - policy = turn_context.approval_policy.value() - ); - - pretty_assertions::assert_eq!(output, expected); - } -} +#[path = "codex_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs new file mode 100644 index 0000000000..cb44d1cada --- /dev/null +++ b/codex-rs/core/src/codex_tests.rs @@ -0,0 +1,3939 @@ +use super::*; +use crate::CodexAuth; +use crate::config::ConfigBuilder; +use crate::config::test_config; +use crate::config_loader::ConfigLayerStack; +use crate::config_loader::ConfigLayerStackOrdering; +use crate::config_loader::NetworkConstraints; +use crate::config_loader::RequirementSource; +use crate::config_loader::Sourced; +use crate::exec::ExecToolCallOutput; +use crate::function_tool::FunctionCallError; +use crate::mcp_connection_manager::ToolInfo; +use crate::models_manager::model_info; +use crate::shell::default_user_shell; +use crate::tools::format_exec_output_str; + +use codex_protocol::ThreadId; +use codex_protocol::models::FunctionCallOutputBody; +use codex_protocol::models::FunctionCallOutputPayload; +use tracing::Span; + +use crate::protocol::CompactedItem; +use crate::protocol::CreditsSnapshot; +use crate::protocol::InitialHistory; +use crate::protocol::NetworkApprovalProtocol; +use crate::protocol::RateLimitSnapshot; +use crate::protocol::RateLimitWindow; +use crate::protocol::ResumedHistory; +use crate::protocol::TokenCountEvent; +use crate::protocol::TokenUsage; +use crate::protocol::TokenUsageInfo; +use crate::protocol::TurnCompleteEvent; +use crate::protocol::UserMessageEvent; +use crate::rollout::policy::EventPersistenceMode; +use crate::rollout::recorder::RolloutRecorder; +use crate::rollout::recorder::RolloutRecorderParams; +use crate::state::TaskKind; +use crate::tasks::SessionTask; +use crate::tasks::SessionTaskContext; +use crate::tools::ToolRouter; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolOutput; +use crate::tools::context::ToolPayload; +use crate::tools::handlers::ShellHandler; +use crate::tools::handlers::UnifiedExecHandler; +use crate::tools::registry::ToolHandler; +use crate::tools::router::ToolCallSource; +use crate::turn_diff_tracker::TurnDiffTracker; +use codex_app_server_protocol::AppInfo; +use codex_otel::TelemetryAuthMode; +use codex_protocol::models::BaseInstructions; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseInputItem; +use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ModelsResponse; +use codex_protocol::protocol::ConversationAudioParams; +use codex_protocol::protocol::RealtimeAudioFrame; +use codex_protocol::protocol::Submission; +use codex_protocol::protocol::W3cTraceContext; +use opentelemetry::trace::TraceContextExt; +use opentelemetry::trace::TraceId; +use opentelemetry::trace::TracerProvider as _; +use opentelemetry_sdk::trace::SdkTracerProvider; +use std::path::Path; +use std::time::Duration; +use tokio::time::sleep; +use tracing_opentelemetry::OpenTelemetrySpanExt; +use tracing_subscriber::prelude::*; + +use codex_protocol::mcp::CallToolResult as McpCallToolResult; +use pretty_assertions::assert_eq; +use rmcp::model::JsonObject; +use rmcp::model::Tool; +use serde::Deserialize; +use serde_json::json; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::Once; +use std::time::Duration as StdDuration; + +struct InstructionsTestCase { + slug: &'static str, + expects_apply_patch_instructions: bool, +} + +fn user_message(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: text.to_string(), + }], + end_turn: None, + phase: None, + } +} + +fn assistant_message(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: text.to_string(), + }], + end_turn: None, + phase: None, + } +} + +fn skill_message(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: text.to_string(), + }], + end_turn: None, + phase: None, + } +} + +fn developer_input_texts(items: &[ResponseItem]) -> Vec<&str> { + items + .iter() + .filter_map(|item| match item { + ResponseItem::Message { role, content, .. } if role == "developer" => { + Some(content.as_slice()) + } + _ => None, + }) + .flat_map(|content| content.iter()) + .filter_map(|item| match item { + ContentItem::InputText { text } => Some(text.as_str()), + _ => None, + }) + .collect() +} + +fn make_connector(id: &str, name: &str) -> AppInfo { + AppInfo { + id: id.to_string(), + name: name.to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: true, + is_enabled: true, + plugin_display_names: Vec::new(), + } +} + +#[test] +fn assistant_message_stream_parsers_can_be_seeded_from_output_item_added_text() { + let mut parsers = AssistantMessageStreamParsers::new(false); + let item_id = "msg-1"; + + let seeded = parsers.seed_item_text(item_id, "hello doc"); + let parsed = parsers.parse_delta(item_id, "1 world"); + let tail = parsers.finish_item(item_id); + + assert_eq!(seeded.visible_text, "hello "); + assert_eq!(seeded.citations, Vec::::new()); + assert_eq!(parsed.visible_text, " world"); + assert_eq!(parsed.citations, vec!["doc1".to_string()]); + assert_eq!(tail.visible_text, ""); + assert_eq!(tail.citations, Vec::::new()); +} + +#[test] +fn assistant_message_stream_parsers_seed_buffered_prefix_stays_out_of_finish_tail() { + let mut parsers = AssistantMessageStreamParsers::new(false); + let item_id = "msg-1"; + + let seeded = parsers.seed_item_text(item_id, "hello doc world"); + let tail = parsers.finish_item(item_id); + + assert_eq!(seeded.visible_text, "hello "); + assert_eq!(seeded.citations, Vec::::new()); + assert_eq!(parsed.visible_text, " world"); + assert_eq!(parsed.citations, vec!["doc".to_string()]); + assert_eq!(tail.visible_text, ""); + assert_eq!(tail.citations, Vec::::new()); +} + +#[test] +fn assistant_message_stream_parsers_seed_plan_parser_across_added_and_delta_boundaries() { + let mut parsers = AssistantMessageStreamParsers::new(true); + let item_id = "msg-1"; + + let seeded = parsers.seed_item_text(item_id, "Intro\n\n- step\n\nOutro"); + let tail = parsers.finish_item(item_id); + + assert_eq!(seeded.visible_text, "Intro\n"); + assert_eq!( + seeded.plan_segments, + vec![ProposedPlanSegment::Normal("Intro\n".to_string())] + ); + assert_eq!(parsed.visible_text, "Outro"); + assert_eq!( + parsed.plan_segments, + vec![ + ProposedPlanSegment::ProposedPlanStart, + ProposedPlanSegment::ProposedPlanDelta("- step\n".to_string()), + ProposedPlanSegment::ProposedPlanEnd, + ProposedPlanSegment::Normal("Outro".to_string()), + ] + ); + assert_eq!(tail.visible_text, ""); + assert!(tail.plan_segments.is_empty()); +} + +fn make_mcp_tool( + server_name: &str, + tool_name: &str, + connector_id: Option<&str>, + connector_name: Option<&str>, +) -> ToolInfo { + ToolInfo { + server_name: server_name.to_string(), + tool_name: tool_name.to_string(), + tool: Tool { + name: tool_name.to_string().into(), + title: None, + description: Some(format!("Test tool: {tool_name}").into()), + input_schema: Arc::new(JsonObject::default()), + output_schema: None, + annotations: None, + execution: None, + icons: None, + meta: None, + }, + connector_id: connector_id.map(str::to_string), + connector_name: connector_name.map(str::to_string), + plugin_display_names: Vec::new(), + } +} + +fn function_call_rollout_item(name: &str, call_id: &str) -> RolloutItem { + RolloutItem::ResponseItem(ResponseItem::FunctionCall { + id: None, + name: name.to_string(), + arguments: "{}".to_string(), + call_id: call_id.to_string(), + }) +} + +fn function_call_output_rollout_item(call_id: &str, output: &str) -> RolloutItem { + RolloutItem::ResponseItem(ResponseItem::FunctionCallOutput { + call_id: call_id.to_string(), + output: FunctionCallOutputPayload::from_text(output.to_string()), + }) +} + +#[test] +fn validated_network_policy_amendment_host_allows_normalized_match() { + let amendment = NetworkPolicyAmendment { + host: "ExAmPlE.Com.:443".to_string(), + action: NetworkPolicyRuleAction::Allow, + }; + let context = NetworkApprovalContext { + host: "example.com".to_string(), + protocol: NetworkApprovalProtocol::Https, + }; + + let host = Session::validated_network_policy_amendment_host(&amendment, &context) + .expect("normalized hosts should match"); + + assert_eq!(host, "example.com"); +} + +#[test] +fn validated_network_policy_amendment_host_rejects_mismatch() { + let amendment = NetworkPolicyAmendment { + host: "evil.example.com".to_string(), + action: NetworkPolicyRuleAction::Deny, + }; + let context = NetworkApprovalContext { + host: "api.example.com".to_string(), + protocol: NetworkApprovalProtocol::Https, + }; + + let err = Session::validated_network_policy_amendment_host(&amendment, &context) + .expect_err("mismatched hosts should be rejected"); + + let message = err.to_string(); + assert!(message.contains("does not match approved host")); +} + +#[tokio::test] +async fn get_base_instructions_no_user_content() { + let prompt_with_apply_patch_instructions = + include_str!("../prompt_with_apply_patch_instructions.md"); + let models_response: ModelsResponse = + serde_json::from_str(include_str!("../models.json")).expect("valid models.json"); + let model_info_for_slug = |slug: &str, config: &Config| { + let model = models_response + .models + .iter() + .find(|candidate| candidate.slug == slug) + .cloned() + .unwrap_or_else(|| panic!("model slug {slug} is missing from models.json")); + model_info::with_config_overrides(model, config) + }; + let test_cases = vec![ + InstructionsTestCase { + slug: "gpt-5", + expects_apply_patch_instructions: false, + }, + InstructionsTestCase { + slug: "gpt-5.1", + expects_apply_patch_instructions: false, + }, + InstructionsTestCase { + slug: "gpt-5.1-codex", + expects_apply_patch_instructions: false, + }, + InstructionsTestCase { + slug: "gpt-5.1-codex-max", + expects_apply_patch_instructions: false, + }, + ]; + + let (session, _turn_context) = make_session_and_context().await; + let config = test_config(); + + for test_case in test_cases { + let model_info = model_info_for_slug(test_case.slug, &config); + if test_case.expects_apply_patch_instructions { + assert_eq!( + model_info.base_instructions.as_str(), + prompt_with_apply_patch_instructions + ); + } + + { + let mut state = session.state.lock().await; + state.session_configuration.base_instructions = model_info.base_instructions.clone(); + } + + let base_instructions = session.get_base_instructions().await; + assert_eq!(base_instructions.text, model_info.base_instructions); + } +} + +#[tokio::test] +async fn reload_user_config_layer_updates_effective_apps_config() { + let (session, _turn_context) = make_session_and_context().await; + let codex_home = session.codex_home().await; + std::fs::create_dir_all(&codex_home).expect("create codex home"); + let config_toml_path = codex_home.join(CONFIG_TOML_FILE); + std::fs::write( + &config_toml_path, + "[apps.calendar]\nenabled = false\ndestructive_enabled = false\n", + ) + .expect("write user config"); + + session.reload_user_config_layer().await; + + let config = session.get_config().await; + let apps_toml = config + .config_layer_stack + .effective_config() + .as_table() + .and_then(|table| table.get("apps")) + .cloned() + .expect("apps table"); + let apps = crate::config::types::AppsConfigToml::deserialize(apps_toml) + .expect("deserialize apps config"); + let app = apps + .apps + .get("calendar") + .expect("calendar app config exists"); + + assert!(!app.enabled); + assert_eq!(app.destructive_enabled, Some(false)); +} + +#[test] +fn filter_connectors_for_input_skips_duplicate_slug_mentions() { + let connectors = vec![ + make_connector("one", "Foo Bar"), + make_connector("two", "Foo-Bar"), + ]; + let input = vec![user_message("use $foo-bar")]; + let explicitly_enabled_connectors = HashSet::new(); + let skill_name_counts_lower = HashMap::new(); + + let selected = filter_connectors_for_input( + &connectors, + &input, + &explicitly_enabled_connectors, + &skill_name_counts_lower, + ); + + assert_eq!(selected, Vec::new()); +} + +#[test] +fn filter_connectors_for_input_skips_when_skill_name_conflicts() { + let connectors = vec![make_connector("one", "Todoist")]; + let input = vec![user_message("use $todoist")]; + let explicitly_enabled_connectors = HashSet::new(); + let skill_name_counts_lower = HashMap::from([("todoist".to_string(), 1)]); + + let selected = filter_connectors_for_input( + &connectors, + &input, + &explicitly_enabled_connectors, + &skill_name_counts_lower, + ); + + assert_eq!(selected, Vec::new()); +} + +#[test] +fn filter_connectors_for_input_skips_disabled_connectors() { + let mut connector = make_connector("calendar", "Calendar"); + connector.is_enabled = false; + let input = vec![user_message("use $calendar")]; + let explicitly_enabled_connectors = HashSet::new(); + let selected = filter_connectors_for_input( + &[connector], + &input, + &explicitly_enabled_connectors, + &HashMap::new(), + ); + + assert_eq!(selected, Vec::new()); +} + +#[test] +fn collect_explicit_app_ids_from_skill_items_includes_linked_mentions() { + let connectors = vec![make_connector("calendar", "Calendar")]; + let skill_items = vec![skill_message( + "\ndemo\n/tmp/skills/demo/SKILL.md\nuse [$calendar](app://calendar)\n", + )]; + + let connector_ids = + collect_explicit_app_ids_from_skill_items(&skill_items, &connectors, &HashMap::new()); + + assert_eq!(connector_ids, HashSet::from(["calendar".to_string()])); +} + +#[test] +fn collect_explicit_app_ids_from_skill_items_resolves_unambiguous_plain_mentions() { + let connectors = vec![make_connector("calendar", "Calendar")]; + let skill_items = vec![skill_message( + "\ndemo\n/tmp/skills/demo/SKILL.md\nuse $calendar\n", + )]; + + let connector_ids = + collect_explicit_app_ids_from_skill_items(&skill_items, &connectors, &HashMap::new()); + + assert_eq!(connector_ids, HashSet::from(["calendar".to_string()])); +} + +#[test] +fn collect_explicit_app_ids_from_skill_items_skips_plain_mentions_with_skill_conflicts() { + let connectors = vec![make_connector("calendar", "Calendar")]; + let skill_items = vec![skill_message( + "\ndemo\n/tmp/skills/demo/SKILL.md\nuse $calendar\n", + )]; + let skill_name_counts_lower = HashMap::from([("calendar".to_string(), 1)]); + + let connector_ids = collect_explicit_app_ids_from_skill_items( + &skill_items, + &connectors, + &skill_name_counts_lower, + ); + + assert_eq!(connector_ids, HashSet::::new()); +} + +#[test] +fn non_app_mcp_tools_remain_visible_without_search_selection() { + let mcp_tools = HashMap::from([ + ( + "mcp__codex_apps__calendar_create_event".to_string(), + make_mcp_tool( + CODEX_APPS_MCP_SERVER_NAME, + "calendar_create_event", + Some("calendar"), + Some("Calendar"), + ), + ), + ( + "mcp__rmcp__echo".to_string(), + make_mcp_tool("rmcp", "echo", None, None), + ), + ]); + + let mut selected_mcp_tools = mcp_tools + .iter() + .filter(|(_, tool)| tool.server_name != CODEX_APPS_MCP_SERVER_NAME) + .map(|(name, tool)| (name.clone(), tool.clone())) + .collect::>(); + + let connectors = connectors::accessible_connectors_from_mcp_tools(&mcp_tools); + let explicitly_enabled_connectors = HashSet::new(); + let connectors = filter_connectors_for_input( + &connectors, + &[user_message("run echo")], + &explicitly_enabled_connectors, + &HashMap::new(), + ); + let apps_mcp_tools = filter_codex_apps_mcp_tools_only(&mcp_tools, &connectors); + selected_mcp_tools.extend(apps_mcp_tools); + + let mut tool_names: Vec = selected_mcp_tools.into_keys().collect(); + tool_names.sort(); + assert_eq!(tool_names, vec!["mcp__rmcp__echo".to_string()]); +} + +#[test] +fn search_tool_selection_keeps_codex_apps_tools_without_mentions() { + let selected_tool_names = vec![ + "mcp__codex_apps__calendar_create_event".to_string(), + "mcp__rmcp__echo".to_string(), + ]; + let mcp_tools = HashMap::from([ + ( + "mcp__codex_apps__calendar_create_event".to_string(), + make_mcp_tool( + CODEX_APPS_MCP_SERVER_NAME, + "calendar_create_event", + Some("calendar"), + Some("Calendar"), + ), + ), + ( + "mcp__rmcp__echo".to_string(), + make_mcp_tool("rmcp", "echo", None, None), + ), + ]); + + let mut selected_mcp_tools = filter_mcp_tools_by_name(&mcp_tools, &selected_tool_names); + let connectors = connectors::accessible_connectors_from_mcp_tools(&mcp_tools); + let explicitly_enabled_connectors = HashSet::new(); + let connectors = filter_connectors_for_input( + &connectors, + &[user_message("run the selected tools")], + &explicitly_enabled_connectors, + &HashMap::new(), + ); + let apps_mcp_tools = filter_codex_apps_mcp_tools_only(&mcp_tools, &connectors); + selected_mcp_tools.extend(apps_mcp_tools); + + let mut tool_names: Vec = selected_mcp_tools.into_keys().collect(); + tool_names.sort(); + assert_eq!( + tool_names, + vec![ + "mcp__codex_apps__calendar_create_event".to_string(), + "mcp__rmcp__echo".to_string(), + ] + ); +} + +#[test] +fn apps_mentions_add_codex_apps_tools_to_search_selected_set() { + let selected_tool_names = vec!["mcp__rmcp__echo".to_string()]; + let mcp_tools = HashMap::from([ + ( + "mcp__codex_apps__calendar_create_event".to_string(), + make_mcp_tool( + CODEX_APPS_MCP_SERVER_NAME, + "calendar_create_event", + Some("calendar"), + Some("Calendar"), + ), + ), + ( + "mcp__rmcp__echo".to_string(), + make_mcp_tool("rmcp", "echo", None, None), + ), + ]); + + let mut selected_mcp_tools = filter_mcp_tools_by_name(&mcp_tools, &selected_tool_names); + let connectors = connectors::accessible_connectors_from_mcp_tools(&mcp_tools); + let explicitly_enabled_connectors = HashSet::new(); + let connectors = filter_connectors_for_input( + &connectors, + &[user_message("use $calendar and then echo the response")], + &explicitly_enabled_connectors, + &HashMap::new(), + ); + let apps_mcp_tools = filter_codex_apps_mcp_tools_only(&mcp_tools, &connectors); + selected_mcp_tools.extend(apps_mcp_tools); + + let mut tool_names: Vec = selected_mcp_tools.into_keys().collect(); + tool_names.sort(); + assert_eq!( + tool_names, + vec![ + "mcp__codex_apps__calendar_create_event".to_string(), + "mcp__rmcp__echo".to_string(), + ] + ); +} + +#[test] +fn extract_mcp_tool_selection_from_rollout_reads_search_tool_output() { + let rollout_items = vec![ + function_call_rollout_item(SEARCH_TOOL_BM25_TOOL_NAME, "search-1"), + function_call_output_rollout_item( + "search-1", + &json!({ + "active_selected_tools": [ + "mcp__codex_apps__calendar_create_event", + "mcp__codex_apps__calendar_list_events", + ], + }) + .to_string(), + ), + ]; + + let selected = Session::extract_mcp_tool_selection_from_rollout(&rollout_items); + assert_eq!( + selected, + Some(vec![ + "mcp__codex_apps__calendar_create_event".to_string(), + "mcp__codex_apps__calendar_list_events".to_string(), + ]) + ); +} + +#[test] +fn extract_mcp_tool_selection_from_rollout_latest_valid_payload_wins() { + let rollout_items = vec![ + function_call_rollout_item(SEARCH_TOOL_BM25_TOOL_NAME, "search-1"), + function_call_output_rollout_item( + "search-1", + &json!({ + "active_selected_tools": ["mcp__codex_apps__calendar_create_event"], + }) + .to_string(), + ), + function_call_rollout_item(SEARCH_TOOL_BM25_TOOL_NAME, "search-2"), + function_call_output_rollout_item( + "search-2", + &json!({ + "active_selected_tools": ["mcp__codex_apps__calendar_delete_event"], + }) + .to_string(), + ), + ]; + + let selected = Session::extract_mcp_tool_selection_from_rollout(&rollout_items); + assert_eq!( + selected, + Some(vec!["mcp__codex_apps__calendar_delete_event".to_string(),]) + ); +} + +#[test] +fn extract_mcp_tool_selection_from_rollout_ignores_non_search_and_malformed_payloads() { + let rollout_items = vec![ + function_call_rollout_item("shell", "shell-1"), + function_call_output_rollout_item( + "shell-1", + &json!({ + "active_selected_tools": ["mcp__codex_apps__should_be_ignored"], + }) + .to_string(), + ), + function_call_rollout_item(SEARCH_TOOL_BM25_TOOL_NAME, "search-1"), + function_call_output_rollout_item("search-1", "{not-json"), + function_call_output_rollout_item( + "unknown-search-call", + &json!({ + "active_selected_tools": ["mcp__codex_apps__also_ignored"], + }) + .to_string(), + ), + function_call_output_rollout_item( + "search-1", + &json!({ + "active_selected_tools": ["mcp__codex_apps__calendar_list_events"], + }) + .to_string(), + ), + ]; + + let selected = Session::extract_mcp_tool_selection_from_rollout(&rollout_items); + assert_eq!( + selected, + Some(vec!["mcp__codex_apps__calendar_list_events".to_string(),]) + ); +} + +#[test] +fn extract_mcp_tool_selection_from_rollout_returns_none_without_valid_search_output() { + let rollout_items = vec![function_call_rollout_item( + SEARCH_TOOL_BM25_TOOL_NAME, + "search-1", + )]; + let selected = Session::extract_mcp_tool_selection_from_rollout(&rollout_items); + assert_eq!(selected, None); +} + +#[tokio::test] +async fn reconstruct_history_matches_live_compactions() { + let (session, turn_context) = make_session_and_context().await; + let (rollout_items, expected) = sample_rollout(&session, &turn_context).await; + + let reconstruction_turn = session.new_default_turn().await; + let reconstructed = session + .reconstruct_history_from_rollout(reconstruction_turn.as_ref(), &rollout_items) + .await; + + assert_eq!(expected, reconstructed.history); +} + +#[tokio::test] +async fn reconstruct_history_uses_replacement_history_verbatim() { + let (session, turn_context) = make_session_and_context().await; + let summary_item = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "summary".to_string(), + }], + end_turn: None, + phase: None, + }; + let replacement_history = vec![ + summary_item.clone(), + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "stale developer instructions".to_string(), + }], + end_turn: None, + phase: None, + }, + ]; + let rollout_items = vec![RolloutItem::Compacted(CompactedItem { + message: String::new(), + replacement_history: Some(replacement_history.clone()), + })]; + + let reconstructed = session + .reconstruct_history_from_rollout(&turn_context, &rollout_items) + .await; + + assert_eq!(reconstructed.history, replacement_history); +} + +#[tokio::test] +async fn record_initial_history_reconstructs_resumed_transcript() { + let (session, turn_context) = make_session_and_context().await; + let (rollout_items, expected) = sample_rollout(&session, &turn_context).await; + + session + .record_initial_history(InitialHistory::Resumed(ResumedHistory { + conversation_id: ThreadId::default(), + history: rollout_items, + rollout_path: PathBuf::from("/tmp/resume.jsonl"), + })) + .await; + + let history = session.state.lock().await.clone_history(); + assert_eq!(expected, history.raw_items()); +} + +#[tokio::test] +async fn resumed_history_injects_initial_context_on_first_context_update_only() { + let (session, turn_context) = make_session_and_context().await; + let (rollout_items, mut expected) = sample_rollout(&session, &turn_context).await; + + session + .record_initial_history(InitialHistory::Resumed(ResumedHistory { + conversation_id: ThreadId::default(), + history: rollout_items, + rollout_path: PathBuf::from("/tmp/resume.jsonl"), + })) + .await; + + let history_before_seed = session.state.lock().await.clone_history(); + assert_eq!(expected, history_before_seed.raw_items()); + + session + .record_context_updates_and_set_reference_context_item(&turn_context) + .await; + expected.extend(session.build_initial_context(&turn_context).await); + let history_after_seed = session.clone_history().await; + assert_eq!(expected, history_after_seed.raw_items()); + + session + .record_context_updates_and_set_reference_context_item(&turn_context) + .await; + let history_after_second_seed = session.clone_history().await; + assert_eq!( + history_after_seed.raw_items(), + history_after_second_seed.raw_items() + ); +} + +#[tokio::test] +async fn record_initial_history_seeds_token_info_from_rollout() { + let (session, turn_context) = make_session_and_context().await; + let (mut rollout_items, _expected) = sample_rollout(&session, &turn_context).await; + + let info1 = TokenUsageInfo { + total_token_usage: TokenUsage { + input_tokens: 10, + cached_input_tokens: 0, + output_tokens: 20, + reasoning_output_tokens: 0, + total_tokens: 30, + }, + last_token_usage: TokenUsage { + input_tokens: 3, + cached_input_tokens: 0, + output_tokens: 4, + reasoning_output_tokens: 0, + total_tokens: 7, + }, + model_context_window: Some(1_000), + }; + let info2 = TokenUsageInfo { + total_token_usage: TokenUsage { + input_tokens: 100, + cached_input_tokens: 50, + output_tokens: 200, + reasoning_output_tokens: 25, + total_tokens: 375, + }, + last_token_usage: TokenUsage { + input_tokens: 10, + cached_input_tokens: 0, + output_tokens: 20, + reasoning_output_tokens: 5, + total_tokens: 35, + }, + model_context_window: Some(2_000), + }; + + rollout_items.push(RolloutItem::EventMsg(EventMsg::TokenCount( + TokenCountEvent { + info: Some(info1), + rate_limits: None, + }, + ))); + rollout_items.push(RolloutItem::EventMsg(EventMsg::TokenCount( + TokenCountEvent { + info: None, + rate_limits: None, + }, + ))); + rollout_items.push(RolloutItem::EventMsg(EventMsg::TokenCount( + TokenCountEvent { + info: Some(info2.clone()), + rate_limits: None, + }, + ))); + rollout_items.push(RolloutItem::EventMsg(EventMsg::TokenCount( + TokenCountEvent { + info: None, + rate_limits: None, + }, + ))); + + session + .record_initial_history(InitialHistory::Resumed(ResumedHistory { + conversation_id: ThreadId::default(), + history: rollout_items, + rollout_path: PathBuf::from("/tmp/resume.jsonl"), + })) + .await; + + let actual = session.state.lock().await.token_info(); + assert_eq!(actual, Some(info2)); +} + +#[tokio::test] +async fn recompute_token_usage_uses_session_base_instructions() { + let (session, turn_context) = make_session_and_context().await; + + let override_instructions = "SESSION_OVERRIDE_INSTRUCTIONS_ONLY".repeat(120); + { + let mut state = session.state.lock().await; + state.session_configuration.base_instructions = override_instructions.clone(); + } + + let item = user_message("hello"); + session + .record_into_history(std::slice::from_ref(&item), &turn_context) + .await; + + let history = session.clone_history().await; + let session_base_instructions = BaseInstructions { + text: override_instructions, + }; + let expected_tokens = history + .estimate_token_count_with_base_instructions(&session_base_instructions) + .expect("estimate with session base instructions"); + let model_estimated_tokens = history + .estimate_token_count(&turn_context) + .expect("estimate with model instructions"); + assert_ne!(expected_tokens, model_estimated_tokens); + + session.recompute_token_usage(&turn_context).await; + + let actual_tokens = session + .state + .lock() + .await + .token_info() + .expect("token info") + .last_token_usage + .total_tokens; + assert_eq!(actual_tokens, expected_tokens.max(0)); +} + +#[tokio::test] +async fn recompute_token_usage_updates_model_context_window() { + let (session, mut turn_context) = make_session_and_context().await; + + { + let mut state = session.state.lock().await; + state.set_token_info(Some(TokenUsageInfo { + total_token_usage: TokenUsage::default(), + last_token_usage: TokenUsage::default(), + model_context_window: Some(258_400), + })); + } + + turn_context.model_info.context_window = Some(128_000); + turn_context.model_info.effective_context_window_percent = 100; + + session.recompute_token_usage(&turn_context).await; + + let actual = session.state.lock().await.token_info().expect("token info"); + assert_eq!(actual.model_context_window, Some(128_000)); +} + +#[tokio::test] +async fn record_initial_history_reconstructs_forked_transcript() { + let (session, turn_context) = make_session_and_context().await; + let (rollout_items, mut expected) = sample_rollout(&session, &turn_context).await; + + session + .record_initial_history(InitialHistory::Forked(rollout_items)) + .await; + + let reconstruction_turn = session.new_default_turn().await; + expected.extend( + session + .build_initial_context(reconstruction_turn.as_ref()) + .await, + ); + let history = session.state.lock().await.clone_history(); + assert_eq!(expected, history.raw_items()); +} + +#[tokio::test] +async fn record_initial_history_forked_hydrates_previous_turn_settings() { + let (session, turn_context) = make_session_and_context().await; + let previous_model = "forked-rollout-model"; + let previous_context_item = TurnContextItem { + turn_id: Some(turn_context.sub_id.clone()), + trace_id: turn_context.trace_id.clone(), + cwd: turn_context.cwd.clone(), + current_date: turn_context.current_date.clone(), + timezone: turn_context.timezone.clone(), + approval_policy: turn_context.approval_policy.value(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + network: None, + model: previous_model.to_string(), + personality: turn_context.personality, + collaboration_mode: Some(turn_context.collaboration_mode.clone()), + realtime_active: Some(turn_context.realtime_active), + effort: turn_context.reasoning_effort, + summary: turn_context.reasoning_summary, + user_instructions: None, + developer_instructions: None, + final_output_json_schema: None, + truncation_policy: Some(turn_context.truncation_policy.into()), + }; + let turn_id = previous_context_item + .turn_id + .clone() + .expect("turn context should have turn_id"); + let rollout_items = vec![ + RolloutItem::EventMsg(EventMsg::TurnStarted( + codex_protocol::protocol::TurnStartedEvent { + turn_id: turn_id.clone(), + model_context_window: Some(128_000), + collaboration_mode_kind: ModeKind::Default, + }, + )), + RolloutItem::EventMsg(EventMsg::UserMessage( + codex_protocol::protocol::UserMessageEvent { + message: "forked seed".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + }, + )), + RolloutItem::TurnContext(previous_context_item), + RolloutItem::EventMsg(EventMsg::TurnComplete( + codex_protocol::protocol::TurnCompleteEvent { + turn_id, + last_agent_message: None, + }, + )), + ]; + + session + .record_initial_history(InitialHistory::Forked(rollout_items)) + .await; + + assert_eq!( + session.previous_turn_settings().await, + Some(PreviousTurnSettings { + model: previous_model.to_string(), + realtime_active: Some(turn_context.realtime_active), + }) + ); +} + +#[tokio::test] +async fn thread_rollback_drops_last_turn_from_history() { + let (sess, tc, rx) = make_session_and_context_with_rx().await; + let rollout_path = attach_rollout_recorder(&sess).await; + + let initial_context = sess.build_initial_context(tc.as_ref()).await; + let turn_1 = vec![ + user_message("turn 1 user"), + assistant_message("turn 1 assistant"), + ]; + let turn_2 = vec![ + user_message("turn 2 user"), + assistant_message("turn 2 assistant"), + ]; + let mut full_history = Vec::new(); + full_history.extend(initial_context.clone()); + full_history.extend(turn_1.clone()); + full_history.extend(turn_2); + sess.replace_history(full_history.clone(), Some(tc.to_turn_context_item())) + .await; + let rollout_items: Vec = full_history + .into_iter() + .map(RolloutItem::ResponseItem) + .collect(); + sess.persist_rollout_items(&rollout_items).await; + sess.set_previous_turn_settings(Some(PreviousTurnSettings { + model: "stale-model".to_string(), + realtime_active: Some(tc.realtime_active), + })) + .await; + { + let mut state = sess.state.lock().await; + state.set_reference_context_item(Some(tc.to_turn_context_item())); + } + + handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; + + let rollback_event = wait_for_thread_rolled_back(&rx).await; + assert_eq!(rollback_event.num_turns, 1); + + let mut expected = Vec::new(); + expected.extend(initial_context); + expected.extend(turn_1); + + let history = sess.clone_history().await; + assert_eq!(expected, history.raw_items()); + assert_eq!(sess.previous_turn_settings().await, None); + assert!(sess.reference_context_item().await.is_none()); + + let InitialHistory::Resumed(resumed) = RolloutRecorder::get_rollout_history(&rollout_path) + .await + .expect("read rollout history") + else { + panic!("expected resumed rollout history"); + }; + assert!(resumed.history.iter().any(|item| { + matches!( + item, + RolloutItem::EventMsg(EventMsg::ThreadRolledBack(rollback)) + if rollback.num_turns == 1 + ) + })); +} + +#[tokio::test] +async fn thread_rollback_clears_history_when_num_turns_exceeds_existing_turns() { + let (sess, tc, rx) = make_session_and_context_with_rx().await; + attach_rollout_recorder(&sess).await; + + let initial_context = sess.build_initial_context(tc.as_ref()).await; + let turn_1 = vec![user_message("turn 1 user")]; + let mut full_history = Vec::new(); + full_history.extend(initial_context.clone()); + full_history.extend(turn_1); + sess.replace_history(full_history.clone(), Some(tc.to_turn_context_item())) + .await; + let rollout_items: Vec = full_history + .into_iter() + .map(RolloutItem::ResponseItem) + .collect(); + sess.persist_rollout_items(&rollout_items).await; + + handlers::thread_rollback(&sess, "sub-1".to_string(), 99).await; + + let rollback_event = wait_for_thread_rolled_back(&rx).await; + assert_eq!(rollback_event.num_turns, 99); + + let history = sess.clone_history().await; + assert_eq!(initial_context, history.raw_items()); +} + +#[tokio::test] +async fn thread_rollback_fails_without_persisted_rollout_path() { + let (sess, tc, rx) = make_session_and_context_with_rx().await; + + let initial_context = sess.build_initial_context(tc.as_ref()).await; + sess.record_into_history(&initial_context, tc.as_ref()) + .await; + + handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; + + let error_event = wait_for_thread_rollback_failed(&rx).await; + assert_eq!( + error_event.message, + "thread rollback requires a persisted rollout path" + ); + assert_eq!( + error_event.codex_error_info, + Some(CodexErrorInfo::ThreadRollbackFailed) + ); + assert_eq!(sess.clone_history().await.raw_items(), initial_context); +} + +#[tokio::test] +async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context_from_replay() { + let (sess, tc, rx) = make_session_and_context_with_rx().await; + attach_rollout_recorder(&sess).await; + + let first_context_item = tc.to_turn_context_item(); + let first_turn_id = first_context_item + .turn_id + .clone() + .expect("turn context should have turn_id"); + let mut rolled_back_context_item = first_context_item.clone(); + rolled_back_context_item.turn_id = Some("rolled-back-turn".to_string()); + rolled_back_context_item.model = "rolled-back-model".to_string(); + let rolled_back_turn_id = rolled_back_context_item + .turn_id + .clone() + .expect("turn context should have turn_id"); + let turn_one_user = user_message("turn 1 user"); + let turn_one_assistant = assistant_message("turn 1 assistant"); + let turn_two_user = user_message("turn 2 user"); + let turn_two_assistant = assistant_message("turn 2 assistant"); + + sess.persist_rollout_items(&[ + RolloutItem::EventMsg(EventMsg::TurnStarted( + codex_protocol::protocol::TurnStartedEvent { + turn_id: first_turn_id.clone(), + model_context_window: Some(128_000), + collaboration_mode_kind: ModeKind::Default, + }, + )), + RolloutItem::EventMsg(EventMsg::UserMessage( + codex_protocol::protocol::UserMessageEvent { + message: "turn 1 user".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + }, + )), + RolloutItem::TurnContext(first_context_item.clone()), + RolloutItem::ResponseItem(turn_one_user.clone()), + RolloutItem::ResponseItem(turn_one_assistant.clone()), + RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: first_turn_id, + last_agent_message: None, + })), + RolloutItem::EventMsg(EventMsg::TurnStarted( + codex_protocol::protocol::TurnStartedEvent { + turn_id: rolled_back_turn_id.clone(), + model_context_window: Some(128_000), + collaboration_mode_kind: ModeKind::Default, + }, + )), + RolloutItem::EventMsg(EventMsg::UserMessage( + codex_protocol::protocol::UserMessageEvent { + message: "turn 2 user".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + }, + )), + RolloutItem::TurnContext(rolled_back_context_item), + RolloutItem::ResponseItem(turn_two_user), + RolloutItem::ResponseItem(turn_two_assistant), + RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: rolled_back_turn_id, + last_agent_message: None, + })), + ]) + .await; + sess.replace_history( + vec![assistant_message("stale history")], + Some(first_context_item.clone()), + ) + .await; + sess.set_previous_turn_settings(Some(PreviousTurnSettings { + model: "stale-model".to_string(), + realtime_active: None, + })) + .await; + + handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; + let rollback_event = wait_for_thread_rolled_back(&rx).await; + assert_eq!(rollback_event.num_turns, 1); + + assert_eq!( + sess.clone_history().await.raw_items(), + vec![turn_one_user, turn_one_assistant] + ); + assert_eq!( + sess.previous_turn_settings().await, + Some(PreviousTurnSettings { + model: tc.model_info.slug.clone(), + realtime_active: Some(tc.realtime_active), + }) + ); + assert_eq!( + serde_json::to_value(sess.reference_context_item().await) + .expect("serialize replay reference context item"), + serde_json::to_value(Some(first_context_item)) + .expect("serialize expected reference context item") + ); +} + +#[tokio::test] +async fn thread_rollback_persists_marker_and_replays_cumulatively() { + let (sess, tc, rx) = make_session_and_context_with_rx().await; + let rollout_path = attach_rollout_recorder(&sess).await; + let turn_context_item = tc.to_turn_context_item(); + + sess.persist_rollout_items(&[ + RolloutItem::EventMsg(EventMsg::TurnStarted( + codex_protocol::protocol::TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: Some(128_000), + collaboration_mode_kind: ModeKind::Default, + }, + )), + RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "turn 1 user".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + })), + RolloutItem::TurnContext(turn_context_item.clone()), + RolloutItem::ResponseItem(user_message("turn 1 user")), + RolloutItem::ResponseItem(assistant_message("turn 1 assistant")), + RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: None, + })), + RolloutItem::EventMsg(EventMsg::TurnStarted( + codex_protocol::protocol::TurnStartedEvent { + turn_id: "turn-2".to_string(), + model_context_window: Some(128_000), + collaboration_mode_kind: ModeKind::Default, + }, + )), + RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "turn 2 user".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + })), + RolloutItem::TurnContext(turn_context_item.clone()), + RolloutItem::ResponseItem(user_message("turn 2 user")), + RolloutItem::ResponseItem(assistant_message("turn 2 assistant")), + RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-2".to_string(), + last_agent_message: None, + })), + RolloutItem::EventMsg(EventMsg::TurnStarted( + codex_protocol::protocol::TurnStartedEvent { + turn_id: "turn-3".to_string(), + model_context_window: Some(128_000), + collaboration_mode_kind: ModeKind::Default, + }, + )), + RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "turn 3 user".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + })), + RolloutItem::TurnContext(turn_context_item), + RolloutItem::ResponseItem(user_message("turn 3 user")), + RolloutItem::ResponseItem(assistant_message("turn 3 assistant")), + RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-3".to_string(), + last_agent_message: None, + })), + ]) + .await; + + handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; + let first_rollback = wait_for_thread_rolled_back(&rx).await; + assert_eq!(first_rollback.num_turns, 1); + handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; + let second_rollback = wait_for_thread_rolled_back(&rx).await; + assert_eq!(second_rollback.num_turns, 1); + + assert_eq!( + sess.clone_history().await.raw_items(), + vec![ + user_message("turn 1 user"), + assistant_message("turn 1 assistant") + ] + ); + + let InitialHistory::Resumed(resumed) = RolloutRecorder::get_rollout_history(&rollout_path) + .await + .expect("read rollout history") + else { + panic!("expected resumed rollout history"); + }; + let rollback_markers = resumed + .history + .iter() + .filter(|item| matches!(item, RolloutItem::EventMsg(EventMsg::ThreadRolledBack(_)))) + .count(); + assert_eq!(rollback_markers, 2); +} + +#[tokio::test] +async fn thread_rollback_fails_when_turn_in_progress() { + let (sess, tc, rx) = make_session_and_context_with_rx().await; + + let initial_context = sess.build_initial_context(tc.as_ref()).await; + sess.record_into_history(&initial_context, tc.as_ref()) + .await; + + *sess.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); + handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; + + let error_event = wait_for_thread_rollback_failed(&rx).await; + assert_eq!( + error_event.codex_error_info, + Some(CodexErrorInfo::ThreadRollbackFailed) + ); + + let history = sess.clone_history().await; + assert_eq!(initial_context, history.raw_items()); +} + +#[tokio::test] +async fn thread_rollback_fails_when_num_turns_is_zero() { + let (sess, tc, rx) = make_session_and_context_with_rx().await; + + let initial_context = sess.build_initial_context(tc.as_ref()).await; + sess.record_into_history(&initial_context, tc.as_ref()) + .await; + + handlers::thread_rollback(&sess, "sub-1".to_string(), 0).await; + + let error_event = wait_for_thread_rollback_failed(&rx).await; + assert_eq!(error_event.message, "num_turns must be >= 1"); + assert_eq!( + error_event.codex_error_info, + Some(CodexErrorInfo::ThreadRollbackFailed) + ); + + let history = sess.clone_history().await; + assert_eq!(initial_context, history.raw_items()); +} + +#[tokio::test] +async fn set_rate_limits_retains_previous_credits() { + let codex_home = tempfile::tempdir().expect("create temp dir"); + let config = build_test_config(codex_home.path()).await; + let config = Arc::new(config); + let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref()); + let model_info = ModelsManager::construct_model_info_offline_for_tests(model.as_str(), &config); + let reasoning_effort = config.model_reasoning_effort; + let collaboration_mode = CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model, + reasoning_effort, + developer_instructions: None, + }, + }; + let session_configuration = SessionConfiguration { + provider: config.model_provider.clone(), + collaboration_mode, + model_reasoning_summary: config.model_reasoning_summary, + developer_instructions: config.developer_instructions.clone(), + user_instructions: config.user_instructions.clone(), + service_tier: None, + personality: config.personality, + base_instructions: config + .base_instructions + .clone() + .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), + compact_prompt: config.compact_prompt.clone(), + approval_policy: config.permissions.approval_policy.clone(), + sandbox_policy: config.permissions.sandbox_policy.clone(), + windows_sandbox_level: WindowsSandboxLevel::from_config(&config), + cwd: config.cwd.clone(), + codex_home: config.codex_home.clone(), + thread_name: None, + original_config_do_not_use: Arc::clone(&config), + metrics_service_name: None, + app_server_client_name: None, + session_source: SessionSource::Exec, + dynamic_tools: Vec::new(), + persist_extended_history: false, + inherited_shell_snapshot: None, + }; + + let mut state = SessionState::new(session_configuration); + let initial = RateLimitSnapshot { + limit_id: None, + limit_name: None, + primary: Some(RateLimitWindow { + used_percent: 10.0, + window_minutes: Some(15), + resets_at: Some(1_700), + }), + secondary: None, + credits: Some(CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: Some("10.00".to_string()), + }), + plan_type: Some(codex_protocol::account::PlanType::Plus), + }; + state.set_rate_limits(initial.clone()); + + let update = RateLimitSnapshot { + limit_id: Some("codex_other".to_string()), + limit_name: Some("codex_other".to_string()), + primary: Some(RateLimitWindow { + used_percent: 40.0, + window_minutes: Some(30), + resets_at: Some(1_800), + }), + secondary: Some(RateLimitWindow { + used_percent: 5.0, + window_minutes: Some(60), + resets_at: Some(1_900), + }), + credits: None, + plan_type: None, + }; + state.set_rate_limits(update.clone()); + + assert_eq!( + state.latest_rate_limits, + Some(RateLimitSnapshot { + limit_id: Some("codex_other".to_string()), + limit_name: Some("codex_other".to_string()), + primary: update.primary.clone(), + secondary: update.secondary, + credits: initial.credits, + plan_type: initial.plan_type, + }) + ); +} + +#[tokio::test] +async fn set_rate_limits_updates_plan_type_when_present() { + let codex_home = tempfile::tempdir().expect("create temp dir"); + let config = build_test_config(codex_home.path()).await; + let config = Arc::new(config); + let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref()); + let model_info = ModelsManager::construct_model_info_offline_for_tests(model.as_str(), &config); + let reasoning_effort = config.model_reasoning_effort; + let collaboration_mode = CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model, + reasoning_effort, + developer_instructions: None, + }, + }; + let session_configuration = SessionConfiguration { + provider: config.model_provider.clone(), + collaboration_mode, + model_reasoning_summary: config.model_reasoning_summary, + developer_instructions: config.developer_instructions.clone(), + user_instructions: config.user_instructions.clone(), + service_tier: None, + personality: config.personality, + base_instructions: config + .base_instructions + .clone() + .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), + compact_prompt: config.compact_prompt.clone(), + approval_policy: config.permissions.approval_policy.clone(), + sandbox_policy: config.permissions.sandbox_policy.clone(), + windows_sandbox_level: WindowsSandboxLevel::from_config(&config), + cwd: config.cwd.clone(), + codex_home: config.codex_home.clone(), + thread_name: None, + original_config_do_not_use: Arc::clone(&config), + metrics_service_name: None, + app_server_client_name: None, + session_source: SessionSource::Exec, + dynamic_tools: Vec::new(), + persist_extended_history: false, + inherited_shell_snapshot: None, + }; + + let mut state = SessionState::new(session_configuration); + let initial = RateLimitSnapshot { + limit_id: None, + limit_name: None, + primary: Some(RateLimitWindow { + used_percent: 15.0, + window_minutes: Some(20), + resets_at: Some(1_600), + }), + secondary: Some(RateLimitWindow { + used_percent: 5.0, + window_minutes: Some(45), + resets_at: Some(1_650), + }), + credits: Some(CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: Some("15.00".to_string()), + }), + plan_type: Some(codex_protocol::account::PlanType::Plus), + }; + state.set_rate_limits(initial.clone()); + + let update = RateLimitSnapshot { + limit_id: None, + limit_name: None, + primary: Some(RateLimitWindow { + used_percent: 35.0, + window_minutes: Some(25), + resets_at: Some(1_700), + }), + secondary: None, + credits: None, + plan_type: Some(codex_protocol::account::PlanType::Pro), + }; + state.set_rate_limits(update.clone()); + + assert_eq!( + state.latest_rate_limits, + Some(RateLimitSnapshot { + limit_id: Some("codex".to_string()), + limit_name: None, + primary: update.primary, + secondary: update.secondary, + credits: initial.credits, + plan_type: update.plan_type, + }) + ); +} + +#[test] +fn prefers_structured_content_when_present() { + let ctr = McpCallToolResult { + // Content present but should be ignored because structured_content is set. + content: vec![text_block("ignored")], + is_error: None, + structured_content: Some(json!({ + "ok": true, + "value": 42 + })), + meta: None, + }; + + let got = FunctionCallOutputPayload::from(&ctr); + let expected = FunctionCallOutputPayload { + body: FunctionCallOutputBody::Text( + serde_json::to_string(&json!({ + "ok": true, + "value": 42 + })) + .unwrap(), + ), + success: Some(true), + }; + + assert_eq!(expected, got); +} + +#[tokio::test] +async fn includes_timed_out_message() { + let exec = ExecToolCallOutput { + exit_code: 0, + stdout: StreamOutput::new(String::new()), + stderr: StreamOutput::new(String::new()), + aggregated_output: StreamOutput::new("Command output".to_string()), + duration: StdDuration::from_secs(1), + timed_out: true, + }; + let (_, turn_context) = make_session_and_context().await; + + let out = format_exec_output_str(&exec, turn_context.truncation_policy); + + assert_eq!( + out, + "command timed out after 1000 milliseconds\nCommand output" + ); +} + +#[tokio::test] +async fn turn_context_with_model_updates_model_fields() { + let (session, mut turn_context) = make_session_and_context().await; + turn_context.reasoning_effort = Some(ReasoningEffortConfig::Minimal); + let updated = turn_context + .with_model("gpt-5.1".to_string(), &session.services.models_manager) + .await; + let expected_model_info = session + .services + .models_manager + .get_model_info("gpt-5.1", updated.config.as_ref()) + .await; + + assert_eq!(updated.config.model.as_deref(), Some("gpt-5.1")); + assert_eq!(updated.collaboration_mode.model(), "gpt-5.1"); + assert_eq!(updated.model_info, expected_model_info); + assert_eq!( + updated.reasoning_effort, + Some(ReasoningEffortConfig::Medium) + ); + assert_eq!( + updated.collaboration_mode.reasoning_effort(), + Some(ReasoningEffortConfig::Medium) + ); + assert_eq!( + updated.config.model_reasoning_effort, + Some(ReasoningEffortConfig::Medium) + ); + assert_eq!( + updated.truncation_policy, + expected_model_info.truncation_policy.into() + ); + assert!(!Arc::ptr_eq( + &updated.tool_call_gate, + &turn_context.tool_call_gate + )); +} + +#[test] +fn falls_back_to_content_when_structured_is_null() { + let ctr = McpCallToolResult { + content: vec![text_block("hello"), text_block("world")], + is_error: None, + structured_content: Some(serde_json::Value::Null), + meta: None, + }; + + let got = FunctionCallOutputPayload::from(&ctr); + let expected = FunctionCallOutputPayload { + body: FunctionCallOutputBody::Text( + serde_json::to_string(&vec![text_block("hello"), text_block("world")]).unwrap(), + ), + success: Some(true), + }; + + assert_eq!(expected, got); +} + +#[test] +fn success_flag_reflects_is_error_true() { + let ctr = McpCallToolResult { + content: vec![text_block("unused")], + is_error: Some(true), + structured_content: Some(json!({ "message": "bad" })), + meta: None, + }; + + let got = FunctionCallOutputPayload::from(&ctr); + let expected = FunctionCallOutputPayload { + body: FunctionCallOutputBody::Text( + serde_json::to_string(&json!({ "message": "bad" })).unwrap(), + ), + success: Some(false), + }; + + assert_eq!(expected, got); +} + +#[test] +fn success_flag_true_with_no_error_and_content_used() { + let ctr = McpCallToolResult { + content: vec![text_block("alpha")], + is_error: Some(false), + structured_content: None, + meta: None, + }; + + let got = FunctionCallOutputPayload::from(&ctr); + let expected = FunctionCallOutputPayload { + body: FunctionCallOutputBody::Text( + serde_json::to_string(&vec![text_block("alpha")]).unwrap(), + ), + success: Some(true), + }; + + assert_eq!(expected, got); +} + +async fn wait_for_thread_rolled_back( + rx: &async_channel::Receiver, +) -> crate::protocol::ThreadRolledBackEvent { + let deadline = StdDuration::from_secs(2); + let start = std::time::Instant::now(); + loop { + let remaining = deadline.saturating_sub(start.elapsed()); + let evt = tokio::time::timeout(remaining, rx.recv()) + .await + .expect("timeout waiting for event") + .expect("event"); + match evt.msg { + EventMsg::ThreadRolledBack(payload) => return payload, + _ => continue, + } + } +} + +async fn wait_for_thread_rollback_failed(rx: &async_channel::Receiver) -> ErrorEvent { + let deadline = StdDuration::from_secs(2); + let start = std::time::Instant::now(); + loop { + let remaining = deadline.saturating_sub(start.elapsed()); + let evt = tokio::time::timeout(remaining, rx.recv()) + .await + .expect("timeout waiting for event") + .expect("event"); + match evt.msg { + EventMsg::Error(payload) + if payload.codex_error_info == Some(CodexErrorInfo::ThreadRollbackFailed) => + { + return payload; + } + _ => continue, + } + } +} + +async fn attach_rollout_recorder(session: &Arc) -> PathBuf { + let config = session.get_config().await; + let recorder = RolloutRecorder::new( + config.as_ref(), + RolloutRecorderParams::new( + ThreadId::default(), + None, + SessionSource::Exec, + BaseInstructions::default(), + Vec::new(), + EventPersistenceMode::Limited, + ), + None, + None, + ) + .await + .expect("create rollout recorder"); + let rollout_path = recorder.rollout_path().to_path_buf(); + { + let mut rollout = session.services.rollout.lock().await; + *rollout = Some(recorder); + } + session.ensure_rollout_materialized().await; + session.flush_rollout().await; + rollout_path +} + +fn text_block(s: &str) -> serde_json::Value { + json!({ + "type": "text", + "text": s, + }) +} + +fn init_test_tracing() { + static INIT: Once = Once::new(); + INIT.call_once(|| { + let provider = SdkTracerProvider::builder().build(); + let tracer = provider.tracer("codex-core-tests"); + let subscriber = + tracing_subscriber::registry().with(tracing_opentelemetry::layer().with_tracer(tracer)); + tracing::subscriber::set_global_default(subscriber) + .expect("global tracing subscriber should only be installed once"); + }); +} + +async fn build_test_config(codex_home: &Path) -> Config { + ConfigBuilder::default() + .codex_home(codex_home.to_path_buf()) + .build() + .await + .expect("load default test config") +} + +fn otel_manager( + conversation_id: ThreadId, + config: &Config, + model_info: &ModelInfo, + session_source: SessionSource, +) -> OtelManager { + OtelManager::new( + conversation_id, + ModelsManager::get_model_offline_for_tests(config.model.as_deref()).as_str(), + model_info.slug.as_str(), + None, + Some("test@test.com".to_string()), + Some(TelemetryAuthMode::Chatgpt), + "test_originator".to_string(), + false, + "test".to_string(), + session_source, + ) +} + +pub(crate) async fn make_session_configuration_for_tests() -> SessionConfiguration { + let codex_home = tempfile::tempdir().expect("create temp dir"); + let config = build_test_config(codex_home.path()).await; + let config = Arc::new(config); + let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref()); + let model_info = ModelsManager::construct_model_info_offline_for_tests(model.as_str(), &config); + let reasoning_effort = config.model_reasoning_effort; + let collaboration_mode = CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model, + reasoning_effort, + developer_instructions: None, + }, + }; + + SessionConfiguration { + provider: config.model_provider.clone(), + collaboration_mode, + model_reasoning_summary: config.model_reasoning_summary, + developer_instructions: config.developer_instructions.clone(), + user_instructions: config.user_instructions.clone(), + service_tier: None, + personality: config.personality, + base_instructions: config + .base_instructions + .clone() + .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), + compact_prompt: config.compact_prompt.clone(), + approval_policy: config.permissions.approval_policy.clone(), + sandbox_policy: config.permissions.sandbox_policy.clone(), + windows_sandbox_level: WindowsSandboxLevel::from_config(&config), + cwd: config.cwd.clone(), + codex_home: config.codex_home.clone(), + thread_name: None, + original_config_do_not_use: Arc::clone(&config), + metrics_service_name: None, + app_server_client_name: None, + session_source: SessionSource::Exec, + dynamic_tools: Vec::new(), + persist_extended_history: false, + inherited_shell_snapshot: None, + } +} + +#[tokio::test] +async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { + let codex_home = tempfile::tempdir().expect("create temp dir"); + let mut config = build_test_config(codex_home.path()).await; + config + .features + .enable(Feature::ShellZshFork) + .expect("test config should allow shell_zsh_fork"); + config.zsh_path = None; + let config = Arc::new(config); + + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let models_manager = Arc::new(ModelsManager::new( + config.codex_home.clone(), + auth_manager.clone(), + None, + CollaborationModesConfig::default(), + )); + let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref()); + let model_info = ModelsManager::construct_model_info_offline_for_tests(model.as_str(), &config); + let collaboration_mode = CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model, + reasoning_effort: config.model_reasoning_effort, + developer_instructions: None, + }, + }; + let session_configuration = SessionConfiguration { + provider: config.model_provider.clone(), + collaboration_mode, + model_reasoning_summary: config.model_reasoning_summary, + developer_instructions: config.developer_instructions.clone(), + user_instructions: config.user_instructions.clone(), + service_tier: None, + personality: config.personality, + base_instructions: config + .base_instructions + .clone() + .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), + compact_prompt: config.compact_prompt.clone(), + approval_policy: config.permissions.approval_policy.clone(), + sandbox_policy: config.permissions.sandbox_policy.clone(), + windows_sandbox_level: WindowsSandboxLevel::from_config(&config), + cwd: config.cwd.clone(), + codex_home: config.codex_home.clone(), + thread_name: None, + original_config_do_not_use: Arc::clone(&config), + metrics_service_name: None, + app_server_client_name: None, + session_source: SessionSource::Exec, + dynamic_tools: Vec::new(), + persist_extended_history: false, + inherited_shell_snapshot: None, + }; + + let (tx_event, _rx_event) = async_channel::unbounded(); + let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit); + let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone())); + let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); + let skills_manager = Arc::new(SkillsManager::new( + config.codex_home.clone(), + Arc::clone(&plugins_manager), + )); + let result = Session::new( + session_configuration, + Arc::clone(&config), + auth_manager, + models_manager, + ExecPolicyManager::default(), + tx_event, + agent_status_tx, + InitialHistory::New, + SessionSource::Exec, + skills_manager, + plugins_manager, + mcp_manager, + Arc::new(FileWatcher::noop()), + AgentControl::default(), + ) + .await; + + let err = match result { + Ok(_) => panic!("expected startup to fail"), + Err(err) => err, + }; + let msg = format!("{err:#}"); + assert!(msg.contains("zsh fork feature enabled, but `zsh_path` is not configured")); +} + +// todo: use online model info +pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { + let (tx_event, _rx_event) = async_channel::unbounded(); + let codex_home = tempfile::tempdir().expect("create temp dir"); + let config = build_test_config(codex_home.path()).await; + let config = Arc::new(config); + let conversation_id = ThreadId::default(); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let models_manager = Arc::new(ModelsManager::new( + config.codex_home.clone(), + auth_manager.clone(), + None, + CollaborationModesConfig::default(), + )); + let agent_control = AgentControl::default(); + let exec_policy = ExecPolicyManager::default(); + let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit); + let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref()); + let model_info = ModelsManager::construct_model_info_offline_for_tests(model.as_str(), &config); + let reasoning_effort = config.model_reasoning_effort; + let collaboration_mode = CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model, + reasoning_effort, + developer_instructions: None, + }, + }; + let session_configuration = SessionConfiguration { + provider: config.model_provider.clone(), + collaboration_mode, + model_reasoning_summary: config.model_reasoning_summary, + developer_instructions: config.developer_instructions.clone(), + user_instructions: config.user_instructions.clone(), + service_tier: None, + personality: config.personality, + base_instructions: config + .base_instructions + .clone() + .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), + compact_prompt: config.compact_prompt.clone(), + approval_policy: config.permissions.approval_policy.clone(), + sandbox_policy: config.permissions.sandbox_policy.clone(), + windows_sandbox_level: WindowsSandboxLevel::from_config(&config), + cwd: config.cwd.clone(), + codex_home: config.codex_home.clone(), + thread_name: None, + original_config_do_not_use: Arc::clone(&config), + metrics_service_name: None, + app_server_client_name: None, + session_source: SessionSource::Exec, + dynamic_tools: Vec::new(), + persist_extended_history: false, + inherited_shell_snapshot: None, + }; + let per_turn_config = Session::build_per_turn_config(&session_configuration); + let model_info = ModelsManager::construct_model_info_offline_for_tests( + session_configuration.collaboration_mode.model(), + &per_turn_config, + ); + let otel_manager = otel_manager( + conversation_id, + config.as_ref(), + &model_info, + session_configuration.session_source.clone(), + ); + + let state = SessionState::new(session_configuration.clone()); + let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone())); + let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); + let skills_manager = Arc::new(SkillsManager::new( + config.codex_home.clone(), + Arc::clone(&plugins_manager), + )); + let network_approval = Arc::new(NetworkApprovalService::default()); + + let file_watcher = Arc::new(FileWatcher::noop()); + let services = SessionServices { + mcp_connection_manager: Arc::new(RwLock::new( + McpConnectionManager::new_mcp_connection_manager_for_tests( + &config.permissions.approval_policy, + ), + )), + mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()), + unified_exec_manager: UnifiedExecProcessManager::new( + config.background_terminal_max_timeout, + ), + shell_zsh_path: None, + main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), + analytics_events_client: AnalyticsEventsClient::new( + Arc::clone(&config), + Arc::clone(&auth_manager), + ), + hooks: Hooks::new(HooksConfig { + legacy_notify_argv: config.notify.clone(), + }), + rollout: Mutex::new(None), + user_shell: Arc::new(default_user_shell()), + shell_snapshot_tx: watch::channel(None).0, + show_raw_agent_reasoning: config.show_raw_agent_reasoning, + exec_policy, + auth_manager: auth_manager.clone(), + otel_manager: otel_manager.clone(), + models_manager: Arc::clone(&models_manager), + tool_approvals: Mutex::new(ApprovalStore::default()), + execve_session_approvals: RwLock::new(HashMap::new()), + skills_manager, + plugins_manager, + mcp_manager, + file_watcher, + agent_control, + network_proxy: None, + network_approval: Arc::clone(&network_approval), + state_db: None, + model_client: ModelClient::new( + Some(auth_manager.clone()), + conversation_id, + session_configuration.provider.clone(), + session_configuration.session_source.clone(), + config.model_verbosity, + ws_version_from_features(config.as_ref()), + config.features.enabled(Feature::EnableRequestCompression), + config.features.enabled(Feature::RuntimeMetrics), + Session::build_model_client_beta_features_header(config.as_ref()), + ), + }; + let js_repl = Arc::new(JsReplHandle::with_node_path( + config.js_repl_node_path.clone(), + config.js_repl_node_module_dirs.clone(), + )); + + let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&per_turn_config)); + let turn_context = Session::make_turn_context( + Some(Arc::clone(&auth_manager)), + &otel_manager, + session_configuration.provider.clone(), + &session_configuration, + per_turn_config, + model_info, + None, + "turn_id".to_string(), + Arc::clone(&js_repl), + skills_outcome, + ); + + let session = Session { + conversation_id, + tx_event, + agent_status: agent_status_tx, + state: Mutex::new(state), + features: config.features.clone(), + pending_mcp_server_refresh_config: Mutex::new(None), + conversation: Arc::new(RealtimeConversationManager::new()), + active_turn: Mutex::new(None), + services, + js_repl, + next_internal_sub_id: AtomicU64::new(0), + }; + + (session, turn_context) +} + +#[tokio::test] +async fn submit_with_id_captures_current_span_trace_context() { + let (session, _turn_context) = make_session_and_context().await; + let (tx_sub, rx_sub) = async_channel::bounded(1); + let (_tx_event, rx_event) = async_channel::unbounded(); + let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit); + let codex = Codex { + tx_sub, + rx_event, + agent_status, + session: Arc::new(session), + }; + + init_test_tracing(); + + let request_parent = W3cTraceContext { + traceparent: Some("00-00000000000000000000000000000011-0000000000000022-01".into()), + tracestate: Some("vendor=value".into()), + }; + let request_span = info_span!("app_server.request"); + assert!(set_parent_from_w3c_trace_context( + &request_span, + &request_parent + )); + + let expected_trace = async { + let expected_trace = + current_span_w3c_trace_context().expect("current span should have trace context"); + codex + .submit_with_id(Submission { + id: "sub-1".into(), + op: Op::Interrupt, + trace: None, + }) + .await + .expect("submit should succeed"); + expected_trace + } + .instrument(request_span) + .await; + + let submitted = rx_sub.recv().await.expect("submission"); + assert_eq!(submitted.trace, Some(expected_trace)); +} + +#[tokio::test] +async fn new_default_turn_captures_current_span_trace_id() { + let (session, _turn_context) = make_session_and_context().await; + + init_test_tracing(); + + let request_parent = W3cTraceContext { + traceparent: Some("00-00000000000000000000000000000011-0000000000000022-01".into()), + tracestate: Some("vendor=value".into()), + }; + let request_span = info_span!("app_server.request"); + assert!(set_parent_from_w3c_trace_context( + &request_span, + &request_parent + )); + + let turn_context_item = async { + let expected_trace_id = Span::current() + .context() + .span() + .span_context() + .trace_id() + .to_string(); + let turn_context = session.new_default_turn().await; + let turn_context_item = turn_context.to_turn_context_item(); + assert_eq!(turn_context_item.trace_id, Some(expected_trace_id)); + turn_context_item + } + .instrument(request_span) + .await; + + assert_eq!( + turn_context_item.trace_id.as_deref(), + Some("00000000000000000000000000000011") + ); +} + +#[test] +fn submission_dispatch_span_prefers_submission_trace_context() { + init_test_tracing(); + + let ambient_parent = W3cTraceContext { + traceparent: Some("00-00000000000000000000000000000033-0000000000000044-01".into()), + tracestate: None, + }; + let ambient_span = info_span!("ambient"); + assert!(set_parent_from_w3c_trace_context( + &ambient_span, + &ambient_parent + )); + + let submission_trace = W3cTraceContext { + traceparent: Some("00-00000000000000000000000000000055-0000000000000066-01".into()), + tracestate: Some("vendor=value".into()), + }; + let dispatch_span = ambient_span.in_scope(|| { + submission_dispatch_span(&Submission { + id: "sub-1".into(), + op: Op::Interrupt, + trace: Some(submission_trace), + }) + }); + + let trace_id = dispatch_span.context().span().span_context().trace_id(); + assert_eq!( + trace_id, + TraceId::from_hex("00000000000000000000000000000055").expect("trace id") + ); +} + +#[test] +fn submission_dispatch_span_uses_debug_for_realtime_audio() { + init_test_tracing(); + + let dispatch_span = submission_dispatch_span(&Submission { + id: "sub-1".into(), + op: Op::RealtimeConversationAudio(ConversationAudioParams { + frame: RealtimeAudioFrame { + data: "ZmFrZQ==".into(), + sample_rate: 16_000, + num_channels: 1, + samples_per_channel: Some(160), + }, + }), + trace: None, + }); + + assert_eq!( + dispatch_span.metadata().expect("span metadata").level(), + &tracing::Level::DEBUG + ); +} + +#[tokio::test] +async fn spawn_task_turn_span_inherits_dispatch_trace_context() { + struct TraceCaptureTask { + captured_trace: Arc>>, + } + + #[async_trait::async_trait] + impl SessionTask for TraceCaptureTask { + fn kind(&self) -> TaskKind { + TaskKind::Regular + } + + fn span_name(&self) -> &'static str { + "session_task.trace_capture" + } + + async fn run( + self: Arc, + _session: Arc, + _ctx: Arc, + _input: Vec, + _cancellation_token: CancellationToken, + ) -> Option { + let mut trace = self + .captured_trace + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *trace = current_span_w3c_trace_context(); + None + } + } + + init_test_tracing(); + + let request_parent = W3cTraceContext { + traceparent: Some("00-00000000000000000000000000000011-0000000000000022-01".into()), + tracestate: Some("vendor=value".into()), + }; + let request_span = tracing::info_span!("app_server.request"); + assert!(set_parent_from_w3c_trace_context( + &request_span, + &request_parent + )); + + let submission_trace = + async { current_span_w3c_trace_context().expect("request span should have trace context") } + .instrument(request_span) + .await; + + let dispatch_span = submission_dispatch_span(&Submission { + id: "sub-1".into(), + op: Op::Interrupt, + trace: Some(submission_trace.clone()), + }); + let dispatch_span_id = dispatch_span.context().span().span_context().span_id(); + + let (sess, tc, rx) = make_session_and_context_with_rx().await; + let captured_trace = Arc::new(std::sync::Mutex::new(None)); + + async { + sess.spawn_task( + Arc::clone(&tc), + vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + TraceCaptureTask { + captured_trace: Arc::clone(&captured_trace), + }, + ) + .await; + } + .instrument(dispatch_span) + .await; + + let evt = tokio::time::timeout(StdDuration::from_secs(2), rx.recv()) + .await + .expect("timeout waiting for turn completion") + .expect("event"); + assert!(matches!(evt.msg, EventMsg::TurnComplete(_))); + + let task_trace = captured_trace + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + .expect("turn task should capture the current span trace context"); + let submission_context = + codex_otel::context_from_w3c_trace_context(&submission_trace).expect("submission"); + let task_context = codex_otel::context_from_w3c_trace_context(&task_trace).expect("task trace"); + + assert_eq!( + task_context.span().span_context().trace_id(), + submission_context.span().span_context().trace_id() + ); + assert_ne!( + task_context.span().span_context().span_id(), + dispatch_span_id + ); +} + +pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( + dynamic_tools: Vec, +) -> ( + Arc, + Arc, + async_channel::Receiver, +) { + let (tx_event, rx_event) = async_channel::unbounded(); + let codex_home = tempfile::tempdir().expect("create temp dir"); + let config = build_test_config(codex_home.path()).await; + let config = Arc::new(config); + let conversation_id = ThreadId::default(); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let models_manager = Arc::new(ModelsManager::new( + config.codex_home.clone(), + auth_manager.clone(), + None, + CollaborationModesConfig::default(), + )); + let agent_control = AgentControl::default(); + let exec_policy = ExecPolicyManager::default(); + let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit); + let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref()); + let model_info = ModelsManager::construct_model_info_offline_for_tests(model.as_str(), &config); + let reasoning_effort = config.model_reasoning_effort; + let collaboration_mode = CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model, + reasoning_effort, + developer_instructions: None, + }, + }; + let session_configuration = SessionConfiguration { + provider: config.model_provider.clone(), + collaboration_mode, + model_reasoning_summary: config.model_reasoning_summary, + developer_instructions: config.developer_instructions.clone(), + user_instructions: config.user_instructions.clone(), + service_tier: None, + personality: config.personality, + base_instructions: config + .base_instructions + .clone() + .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), + compact_prompt: config.compact_prompt.clone(), + approval_policy: config.permissions.approval_policy.clone(), + sandbox_policy: config.permissions.sandbox_policy.clone(), + windows_sandbox_level: WindowsSandboxLevel::from_config(&config), + cwd: config.cwd.clone(), + codex_home: config.codex_home.clone(), + thread_name: None, + original_config_do_not_use: Arc::clone(&config), + metrics_service_name: None, + app_server_client_name: None, + session_source: SessionSource::Exec, + dynamic_tools, + persist_extended_history: false, + inherited_shell_snapshot: None, + }; + let per_turn_config = Session::build_per_turn_config(&session_configuration); + let model_info = ModelsManager::construct_model_info_offline_for_tests( + session_configuration.collaboration_mode.model(), + &per_turn_config, + ); + let otel_manager = otel_manager( + conversation_id, + config.as_ref(), + &model_info, + session_configuration.session_source.clone(), + ); + + let state = SessionState::new(session_configuration.clone()); + let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone())); + let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); + let skills_manager = Arc::new(SkillsManager::new( + config.codex_home.clone(), + Arc::clone(&plugins_manager), + )); + let network_approval = Arc::new(NetworkApprovalService::default()); + + let file_watcher = Arc::new(FileWatcher::noop()); + let services = SessionServices { + mcp_connection_manager: Arc::new(RwLock::new( + McpConnectionManager::new_mcp_connection_manager_for_tests( + &config.permissions.approval_policy, + ), + )), + mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()), + unified_exec_manager: UnifiedExecProcessManager::new( + config.background_terminal_max_timeout, + ), + shell_zsh_path: None, + main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), + analytics_events_client: AnalyticsEventsClient::new( + Arc::clone(&config), + Arc::clone(&auth_manager), + ), + hooks: Hooks::new(HooksConfig { + legacy_notify_argv: config.notify.clone(), + }), + rollout: Mutex::new(None), + user_shell: Arc::new(default_user_shell()), + shell_snapshot_tx: watch::channel(None).0, + show_raw_agent_reasoning: config.show_raw_agent_reasoning, + exec_policy, + auth_manager: Arc::clone(&auth_manager), + otel_manager: otel_manager.clone(), + models_manager: Arc::clone(&models_manager), + tool_approvals: Mutex::new(ApprovalStore::default()), + execve_session_approvals: RwLock::new(HashMap::new()), + skills_manager, + plugins_manager, + mcp_manager, + file_watcher, + agent_control, + network_proxy: None, + network_approval: Arc::clone(&network_approval), + state_db: None, + model_client: ModelClient::new( + Some(Arc::clone(&auth_manager)), + conversation_id, + session_configuration.provider.clone(), + session_configuration.session_source.clone(), + config.model_verbosity, + ws_version_from_features(config.as_ref()), + config.features.enabled(Feature::EnableRequestCompression), + config.features.enabled(Feature::RuntimeMetrics), + Session::build_model_client_beta_features_header(config.as_ref()), + ), + }; + let js_repl = Arc::new(JsReplHandle::with_node_path( + config.js_repl_node_path.clone(), + config.js_repl_node_module_dirs.clone(), + )); + + let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&per_turn_config)); + let turn_context = Arc::new(Session::make_turn_context( + Some(Arc::clone(&auth_manager)), + &otel_manager, + session_configuration.provider.clone(), + &session_configuration, + per_turn_config, + model_info, + None, + "turn_id".to_string(), + Arc::clone(&js_repl), + skills_outcome, + )); + + let session = Arc::new(Session { + conversation_id, + tx_event, + agent_status: agent_status_tx, + state: Mutex::new(state), + features: config.features.clone(), + pending_mcp_server_refresh_config: Mutex::new(None), + conversation: Arc::new(RealtimeConversationManager::new()), + active_turn: Mutex::new(None), + services, + js_repl, + next_internal_sub_id: AtomicU64::new(0), + }); + + (session, turn_context, rx_event) +} + +// Like make_session_and_context, but returns Arc and the event receiver +// so tests can assert on emitted events. +pub(crate) async fn make_session_and_context_with_rx() -> ( + Arc, + Arc, + async_channel::Receiver, +) { + make_session_and_context_with_dynamic_tools_and_rx(Vec::new()).await +} + +#[tokio::test] +async fn refresh_mcp_servers_is_deferred_until_next_turn() { + let (session, turn_context) = make_session_and_context().await; + let old_token = session.mcp_startup_cancellation_token().await; + assert!(!old_token.is_cancelled()); + + let mcp_oauth_credentials_store_mode = + serde_json::to_value(OAuthCredentialsStoreMode::Auto).expect("serialize store mode"); + let refresh_config = McpServerRefreshConfig { + mcp_servers: json!({}), + mcp_oauth_credentials_store_mode, + }; + { + let mut guard = session.pending_mcp_server_refresh_config.lock().await; + *guard = Some(refresh_config); + } + + assert!(!old_token.is_cancelled()); + assert!( + session + .pending_mcp_server_refresh_config + .lock() + .await + .is_some() + ); + + session + .refresh_mcp_servers_if_requested(&turn_context) + .await; + + assert!(old_token.is_cancelled()); + assert!( + session + .pending_mcp_server_refresh_config + .lock() + .await + .is_none() + ); + let new_token = session.mcp_startup_cancellation_token().await; + assert!(!new_token.is_cancelled()); +} + +#[tokio::test] +async fn record_model_warning_appends_user_message() { + let (mut session, turn_context) = make_session_and_context().await; + let features = crate::features::Features::with_defaults().into(); + session.features = features; + + session + .record_model_warning("too many unified exec processes", &turn_context) + .await; + + let history = session.clone_history().await; + let history_items = history.raw_items(); + let last = history_items.last().expect("warning recorded"); + + match last { + ResponseItem::Message { role, content, .. } => { + assert_eq!(role, "user"); + assert_eq!( + content, + &vec![ContentItem::InputText { + text: "Warning: too many unified exec processes".to_string(), + }] + ); + } + other => panic!("expected user message, got {other:?}"), + } +} + +#[tokio::test] +async fn spawn_task_does_not_update_previous_turn_settings_for_non_run_turn_tasks() { + let (sess, tc, _rx) = make_session_and_context_with_rx().await; + sess.set_previous_turn_settings(None).await; + let input = vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }]; + + sess.spawn_task( + Arc::clone(&tc), + input, + NeverEndingTask { + kind: TaskKind::Regular, + listen_to_cancellation_token: true, + }, + ) + .await; + + sess.abort_all_tasks(TurnAbortReason::Interrupted).await; + assert_eq!(sess.previous_turn_settings().await, None); +} + +#[tokio::test] +async fn build_settings_update_items_emits_environment_item_for_network_changes() { + let (session, previous_context) = make_session_and_context().await; + let previous_context = Arc::new(previous_context); + let mut current_context = previous_context + .with_model( + previous_context.model_info.slug.clone(), + &session.services.models_manager, + ) + .await; + + let mut config = (*current_context.config).clone(); + let mut requirements = config.config_layer_stack.requirements().clone(); + requirements.network = Some(Sourced::new( + NetworkConstraints { + allowed_domains: Some(vec!["api.example.com".to_string()]), + denied_domains: Some(vec!["blocked.example.com".to_string()]), + ..Default::default() + }, + RequirementSource::CloudRequirements, + )); + let layers = config + .config_layer_stack + .get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true) + .into_iter() + .cloned() + .collect(); + config.config_layer_stack = ConfigLayerStack::new( + layers, + requirements, + config.config_layer_stack.requirements_toml().clone(), + ) + .expect("rebuild config layer stack with network requirements"); + current_context.config = Arc::new(config); + + let reference_context_item = previous_context.to_turn_context_item(); + let update_items = session + .build_settings_update_items(Some(&reference_context_item), ¤t_context) + .await; + + let environment_update = update_items + .iter() + .find_map(|item| match item { + ResponseItem::Message { role, content, .. } if role == "user" => { + let [ContentItem::InputText { text }] = content.as_slice() else { + return None; + }; + text.contains("").then_some(text) + } + _ => None, + }) + .expect("environment update item should be emitted"); + assert!(environment_update.contains("")); + assert!(environment_update.contains("api.example.com")); + assert!(environment_update.contains("blocked.example.com")); +} + +#[tokio::test] +async fn build_settings_update_items_emits_environment_item_for_time_changes() { + let (session, previous_context) = make_session_and_context().await; + let previous_context = Arc::new(previous_context); + let mut current_context = previous_context + .with_model( + previous_context.model_info.slug.clone(), + &session.services.models_manager, + ) + .await; + current_context.current_date = Some("2026-02-27".to_string()); + current_context.timezone = Some("Europe/Berlin".to_string()); + + let reference_context_item = previous_context.to_turn_context_item(); + let update_items = session + .build_settings_update_items(Some(&reference_context_item), ¤t_context) + .await; + + let environment_update = update_items + .iter() + .find_map(|item| match item { + ResponseItem::Message { role, content, .. } if role == "user" => { + let [ContentItem::InputText { text }] = content.as_slice() else { + return None; + }; + text.contains("").then_some(text) + } + _ => None, + }) + .expect("environment update item should be emitted"); + assert!(environment_update.contains("2026-02-27")); + assert!(environment_update.contains("Europe/Berlin")); +} + +#[tokio::test] +async fn build_settings_update_items_emits_realtime_start_when_session_becomes_live() { + let (session, previous_context) = make_session_and_context().await; + let previous_context = Arc::new(previous_context); + let mut current_context = previous_context + .with_model( + previous_context.model_info.slug.clone(), + &session.services.models_manager, + ) + .await; + current_context.realtime_active = true; + + let update_items = session + .build_settings_update_items( + Some(&previous_context.to_turn_context_item()), + ¤t_context, + ) + .await; + + let developer_texts = developer_input_texts(&update_items); + assert!( + developer_texts + .iter() + .any(|text| text.contains("")), + "expected a realtime start update, got {developer_texts:?}" + ); +} + +#[tokio::test] +async fn build_settings_update_items_emits_realtime_end_when_session_stops_being_live() { + let (session, mut previous_context) = make_session_and_context().await; + previous_context.realtime_active = true; + let mut current_context = previous_context + .with_model( + previous_context.model_info.slug.clone(), + &session.services.models_manager, + ) + .await; + current_context.realtime_active = false; + + let update_items = session + .build_settings_update_items( + Some(&previous_context.to_turn_context_item()), + ¤t_context, + ) + .await; + + let developer_texts = developer_input_texts(&update_items); + assert!( + developer_texts + .iter() + .any(|text| text.contains("Reason: inactive")), + "expected a realtime end update, got {developer_texts:?}" + ); +} + +#[tokio::test] +async fn build_settings_update_items_uses_previous_turn_settings_for_realtime_end() { + let (session, previous_context) = make_session_and_context().await; + let mut previous_context_item = previous_context.to_turn_context_item(); + previous_context_item.realtime_active = None; + let previous_turn_settings = PreviousTurnSettings { + model: previous_context.model_info.slug.clone(), + realtime_active: Some(true), + }; + let mut current_context = previous_context + .with_model( + previous_context.model_info.slug.clone(), + &session.services.models_manager, + ) + .await; + current_context.realtime_active = false; + + session + .set_previous_turn_settings(Some(previous_turn_settings)) + .await; + let update_items = session + .build_settings_update_items(Some(&previous_context_item), ¤t_context) + .await; + + let developer_texts = developer_input_texts(&update_items); + assert!( + developer_texts + .iter() + .any(|text| text.contains("Reason: inactive")), + "expected a realtime end update from previous turn settings, got {developer_texts:?}" + ); +} + +#[tokio::test] +async fn build_initial_context_uses_previous_realtime_state() { + let (session, mut turn_context) = make_session_and_context().await; + turn_context.realtime_active = true; + + let initial_context = session.build_initial_context(&turn_context).await; + let developer_texts = developer_input_texts(&initial_context); + assert!( + developer_texts + .iter() + .any(|text| text.contains("")), + "expected initial context to describe active realtime state, got {developer_texts:?}" + ); + + let previous_context_item = turn_context.to_turn_context_item(); + { + let mut state = session.state.lock().await; + state.set_reference_context_item(Some(previous_context_item)); + } + let resumed_context = session.build_initial_context(&turn_context).await; + let resumed_developer_texts = developer_input_texts(&resumed_context); + assert!( + !resumed_developer_texts + .iter() + .any(|text| text.contains("")), + "did not expect a duplicate realtime update, got {resumed_developer_texts:?}" + ); +} + +#[tokio::test] +async fn build_initial_context_uses_previous_turn_settings_for_realtime_end() { + let (session, turn_context) = make_session_and_context().await; + let previous_turn_settings = PreviousTurnSettings { + model: turn_context.model_info.slug.clone(), + realtime_active: Some(true), + }; + + session + .set_previous_turn_settings(Some(previous_turn_settings)) + .await; + let initial_context = session.build_initial_context(&turn_context).await; + let developer_texts = developer_input_texts(&initial_context); + assert!( + developer_texts + .iter() + .any(|text| text.contains("Reason: inactive")), + "expected initial context to describe an ended realtime session, got {developer_texts:?}" + ); +} + +#[tokio::test] +async fn build_initial_context_restates_realtime_start_when_reference_context_is_missing() { + let (session, mut turn_context) = make_session_and_context().await; + turn_context.realtime_active = true; + let previous_turn_settings = PreviousTurnSettings { + model: turn_context.model_info.slug.clone(), + realtime_active: Some(true), + }; + + session + .set_previous_turn_settings(Some(previous_turn_settings)) + .await; + let initial_context = session.build_initial_context(&turn_context).await; + let developer_texts = developer_input_texts(&initial_context); + assert!( + developer_texts + .iter() + .any(|text| text.contains("")), + "expected initial context to restate active realtime when the reference context is missing, got {developer_texts:?}" + ); +} + +#[tokio::test] +async fn record_context_updates_and_set_reference_context_item_injects_full_context_when_baseline_missing() + { + let (session, turn_context) = make_session_and_context().await; + session + .record_context_updates_and_set_reference_context_item(&turn_context) + .await; + let history = session.clone_history().await; + let initial_context = session.build_initial_context(&turn_context).await; + assert_eq!(history.raw_items().to_vec(), initial_context); + + let current_context = session.reference_context_item().await; + assert_eq!( + serde_json::to_value(current_context).expect("serialize current context item"), + serde_json::to_value(Some(turn_context.to_turn_context_item())) + .expect("serialize expected context item") + ); +} + +#[tokio::test] +async fn record_context_updates_and_set_reference_context_item_reinjects_full_context_after_clear() +{ + let (session, turn_context) = make_session_and_context().await; + let compacted_summary = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!("{}\nsummary", crate::compact::SUMMARY_PREFIX), + }], + end_turn: None, + phase: None, + }; + session + .record_into_history(std::slice::from_ref(&compacted_summary), &turn_context) + .await; + session + .record_context_updates_and_set_reference_context_item(&turn_context) + .await; + { + let mut state = session.state.lock().await; + state.set_reference_context_item(None); + } + session + .replace_history(vec![compacted_summary.clone()], None) + .await; + + session + .record_context_updates_and_set_reference_context_item(&turn_context) + .await; + + let history = session.clone_history().await; + let mut expected_history = vec![compacted_summary]; + expected_history.extend(session.build_initial_context(&turn_context).await); + assert_eq!(history.raw_items().to_vec(), expected_history); +} + +#[tokio::test] +async fn record_context_updates_and_set_reference_context_item_persists_baseline_without_emitting_diffs() + { + let (session, previous_context) = make_session_and_context().await; + let next_model = if previous_context.model_info.slug == "gpt-5.1" { + "gpt-5" + } else { + "gpt-5.1" + }; + let turn_context = previous_context + .with_model(next_model.to_string(), &session.services.models_manager) + .await; + let previous_context_item = previous_context.to_turn_context_item(); + { + let mut state = session.state.lock().await; + state.set_reference_context_item(Some(previous_context_item.clone())); + } + let config = session.get_config().await; + let recorder = RolloutRecorder::new( + config.as_ref(), + RolloutRecorderParams::new( + ThreadId::default(), + None, + SessionSource::Exec, + BaseInstructions::default(), + Vec::new(), + EventPersistenceMode::Limited, + ), + None, + None, + ) + .await + .expect("create rollout recorder"); + let rollout_path = recorder.rollout_path().to_path_buf(); + { + let mut rollout = session.services.rollout.lock().await; + *rollout = Some(recorder); + } + + let update_items = session + .build_settings_update_items(Some(&previous_context_item), &turn_context) + .await; + assert_eq!(update_items, Vec::new()); + + session + .record_context_updates_and_set_reference_context_item(&turn_context) + .await; + + assert_eq!( + session.clone_history().await.raw_items().to_vec(), + Vec::new() + ); + assert_eq!( + serde_json::to_value(session.reference_context_item().await) + .expect("serialize current context item"), + serde_json::to_value(Some(turn_context.to_turn_context_item())) + .expect("serialize expected context item") + ); + session.ensure_rollout_materialized().await; + session.flush_rollout().await; + + let InitialHistory::Resumed(resumed) = RolloutRecorder::get_rollout_history(&rollout_path) + .await + .expect("read rollout history") + else { + panic!("expected resumed rollout history"); + }; + let persisted_turn_context = resumed.history.iter().find_map(|item| match item { + RolloutItem::TurnContext(ctx) => Some(ctx.clone()), + _ => None, + }); + assert_eq!( + serde_json::to_value(persisted_turn_context) + .expect("serialize persisted turn context item"), + serde_json::to_value(Some(turn_context.to_turn_context_item())) + .expect("serialize expected turn context item") + ); +} + +#[tokio::test] +async fn build_initial_context_prepends_model_switch_message() { + let (session, turn_context) = make_session_and_context().await; + let previous_turn_settings = PreviousTurnSettings { + model: "previous-regular-model".to_string(), + realtime_active: None, + }; + + session + .set_previous_turn_settings(Some(previous_turn_settings)) + .await; + let initial_context = session.build_initial_context(&turn_context).await; + + let ResponseItem::Message { role, content, .. } = &initial_context[0] else { + panic!("expected developer message"); + }; + assert_eq!(role, "developer"); + let [ContentItem::InputText { text }, ..] = content.as_slice() else { + panic!("expected developer text"); + }; + assert!(text.contains("")); +} + +#[tokio::test] +async fn record_context_updates_and_set_reference_context_item_persists_full_reinjection_to_rollout() + { + let (session, previous_context) = make_session_and_context().await; + let next_model = if previous_context.model_info.slug == "gpt-5.1" { + "gpt-5" + } else { + "gpt-5.1" + }; + let turn_context = previous_context + .with_model(next_model.to_string(), &session.services.models_manager) + .await; + let config = session.get_config().await; + let recorder = RolloutRecorder::new( + config.as_ref(), + RolloutRecorderParams::new( + ThreadId::default(), + None, + SessionSource::Exec, + BaseInstructions::default(), + Vec::new(), + EventPersistenceMode::Limited, + ), + None, + None, + ) + .await + .expect("create rollout recorder"); + let rollout_path = recorder.rollout_path().to_path_buf(); + { + let mut rollout = session.services.rollout.lock().await; + *rollout = Some(recorder); + } + + session + .persist_rollout_items(&[RolloutItem::EventMsg(EventMsg::UserMessage( + UserMessageEvent { + message: "seed rollout".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + }, + ))]) + .await; + { + let mut state = session.state.lock().await; + state.set_reference_context_item(None); + } + + session + .set_previous_turn_settings(Some(PreviousTurnSettings { + model: previous_context.model_info.slug.clone(), + realtime_active: Some(previous_context.realtime_active), + })) + .await; + session + .record_context_updates_and_set_reference_context_item(&turn_context) + .await; + session.ensure_rollout_materialized().await; + session.flush_rollout().await; + + let InitialHistory::Resumed(resumed) = RolloutRecorder::get_rollout_history(&rollout_path) + .await + .expect("read rollout history") + else { + panic!("expected resumed rollout history"); + }; + let persisted_turn_context = resumed.history.iter().find_map(|item| match item { + RolloutItem::TurnContext(ctx) => Some(ctx.clone()), + _ => None, + }); + + assert_eq!( + serde_json::to_value(persisted_turn_context) + .expect("serialize persisted turn context item"), + serde_json::to_value(Some(turn_context.to_turn_context_item())) + .expect("serialize expected turn context item") + ); +} + +#[tokio::test] +async fn run_user_shell_command_does_not_set_reference_context_item() { + let (session, _turn_context, rx) = make_session_and_context_with_rx().await; + { + let mut state = session.state.lock().await; + state.set_reference_context_item(None); + } + + handlers::run_user_shell_command(&session, "sub-id".to_string(), "echo shell".to_string()) + .await; + + let deadline = StdDuration::from_secs(15); + let start = std::time::Instant::now(); + loop { + let remaining = deadline.saturating_sub(start.elapsed()); + let evt = tokio::time::timeout(remaining, rx.recv()) + .await + .expect("timeout waiting for event") + .expect("event"); + if matches!(evt.msg, EventMsg::TurnComplete(_)) { + break; + } + } + + assert!( + session.reference_context_item().await.is_none(), + "standalone shell tasks should not mutate previous context" + ); +} + +#[derive(Clone, Copy)] +struct NeverEndingTask { + kind: TaskKind, + listen_to_cancellation_token: bool, +} + +#[async_trait::async_trait] +impl SessionTask for NeverEndingTask { + fn kind(&self) -> TaskKind { + self.kind + } + + fn span_name(&self) -> &'static str { + "session_task.never_ending" + } + + async fn run( + self: Arc, + _session: Arc, + _ctx: Arc, + _input: Vec, + cancellation_token: CancellationToken, + ) -> Option { + if self.listen_to_cancellation_token { + cancellation_token.cancelled().await; + return None; + } + loop { + sleep(Duration::from_secs(60)).await; + } + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[test_log::test] +async fn abort_regular_task_emits_turn_aborted_only() { + let (sess, tc, rx) = make_session_and_context_with_rx().await; + let input = vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }]; + sess.spawn_task( + Arc::clone(&tc), + input, + NeverEndingTask { + kind: TaskKind::Regular, + listen_to_cancellation_token: false, + }, + ) + .await; + + sess.abort_all_tasks(TurnAbortReason::Interrupted).await; + + // Interrupts persist a model-visible `` marker into history, but there is no + // separate client-visible event for that marker (only `EventMsg::TurnAborted`). + let evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("timeout waiting for event") + .expect("event"); + match evt.msg { + EventMsg::TurnAborted(e) => assert_eq!(TurnAbortReason::Interrupted, e.reason), + other => panic!("unexpected event: {other:?}"), + } + // No extra events should be emitted after an abort. + assert!(rx.try_recv().is_err()); +} + +#[tokio::test] +async fn abort_gracefully_emits_turn_aborted_only() { + let (sess, tc, rx) = make_session_and_context_with_rx().await; + let input = vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }]; + sess.spawn_task( + Arc::clone(&tc), + input, + NeverEndingTask { + kind: TaskKind::Regular, + listen_to_cancellation_token: true, + }, + ) + .await; + + sess.abort_all_tasks(TurnAbortReason::Interrupted).await; + + // Even if tasks handle cancellation gracefully, interrupts still result in `TurnAborted` + // being the only client-visible signal. + let evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("timeout waiting for event") + .expect("event"); + match evt.msg { + EventMsg::TurnAborted(e) => assert_eq!(TurnAbortReason::Interrupted, e.reason), + other => panic!("unexpected event: {other:?}"), + } + // No extra events should be emitted after an abort. + assert!(rx.try_recv().is_err()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() { + let (sess, tc, rx) = make_session_and_context_with_rx().await; + let input = vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }]; + sess.spawn_task( + Arc::clone(&tc), + input, + NeverEndingTask { + kind: TaskKind::Regular, + listen_to_cancellation_token: false, + }, + ) + .await; + + while rx.try_recv().is_ok() {} + + sess.inject_response_items(vec![ResponseInputItem::Message { + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "late pending input".to_string(), + }], + }]) + .await + .expect("inject pending input into active turn"); + + sess.on_task_finished(Arc::clone(&tc), None).await; + + let history = sess.clone_history().await; + let expected = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "late pending input".to_string(), + }], + end_turn: None, + phase: None, + }; + assert!( + history.raw_items().iter().any(|item| item == &expected), + "expected pending input to be persisted into history on turn completion" + ); + + let first = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("expected raw response item event") + .expect("channel open"); + assert!(matches!(first.msg, EventMsg::RawResponseItem(_))); + + let second = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("expected item started event") + .expect("channel open"); + assert!(matches!( + second.msg, + EventMsg::ItemStarted(ItemStartedEvent { + item: TurnItem::UserMessage(UserMessageItem { content, .. }), + .. + }) if content == vec![UserInput::Text { + text: "late pending input".to_string(), + text_elements: Vec::new(), + }] + )); + + let third = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("expected item completed event") + .expect("channel open"); + assert!(matches!( + third.msg, + EventMsg::ItemCompleted(ItemCompletedEvent { + item: TurnItem::UserMessage(UserMessageItem { content, .. }), + .. + }) if content == vec![UserInput::Text { + text: "late pending input".to_string(), + text_elements: Vec::new(), + }] + )); + + let fourth = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("expected legacy user message event") + .expect("channel open"); + assert!(matches!( + fourth.msg, + EventMsg::UserMessage(UserMessageEvent { + message, + images, + text_elements, + local_images, + }) if message == "late pending input" + && images == Some(Vec::new()) + && text_elements.is_empty() + && local_images.is_empty() + )); + + let fifth = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("expected turn complete event") + .expect("channel open"); + assert!(matches!( + fifth.msg, + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id, + last_agent_message: None, + }) if turn_id == tc.sub_id + )); +} + +#[tokio::test] +async fn steer_input_requires_active_turn() { + let (sess, _tc, _rx) = make_session_and_context_with_rx().await; + let input = vec![UserInput::Text { + text: "steer".to_string(), + text_elements: Vec::new(), + }]; + + let err = sess + .steer_input(input, None) + .await + .expect_err("steering without active turn should fail"); + + assert!(matches!(err, SteerInputError::NoActiveTurn(_))); +} + +#[tokio::test] +async fn steer_input_enforces_expected_turn_id() { + let (sess, tc, _rx) = make_session_and_context_with_rx().await; + let input = vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }]; + sess.spawn_task( + Arc::clone(&tc), + input, + NeverEndingTask { + kind: TaskKind::Regular, + listen_to_cancellation_token: false, + }, + ) + .await; + + let steer_input = vec![UserInput::Text { + text: "steer".to_string(), + text_elements: Vec::new(), + }]; + let err = sess + .steer_input(steer_input, Some("different-turn-id")) + .await + .expect_err("mismatched expected turn id should fail"); + + match err { + SteerInputError::ExpectedTurnMismatch { expected, actual } => { + assert_eq!( + (expected, actual), + ("different-turn-id".to_string(), tc.sub_id.clone()) + ); + } + other => panic!("unexpected error: {other:?}"), + } +} + +#[tokio::test] +async fn steer_input_returns_active_turn_id() { + let (sess, tc, _rx) = make_session_and_context_with_rx().await; + let input = vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }]; + sess.spawn_task( + Arc::clone(&tc), + input, + NeverEndingTask { + kind: TaskKind::Regular, + listen_to_cancellation_token: false, + }, + ) + .await; + + let steer_input = vec![UserInput::Text { + text: "steer".to_string(), + text_elements: Vec::new(), + }]; + let turn_id = sess + .steer_input(steer_input, Some(&tc.sub_id)) + .await + .expect("steering with matching expected turn id should succeed"); + + assert_eq!(turn_id, tc.sub_id); + assert!(sess.has_pending_input().await); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn abort_review_task_emits_exited_then_aborted_and_records_history() { + let (sess, tc, rx) = make_session_and_context_with_rx().await; + let input = vec![UserInput::Text { + text: "start review".to_string(), + text_elements: Vec::new(), + }]; + sess.spawn_task(Arc::clone(&tc), input, ReviewTask::new()) + .await; + + sess.abort_all_tasks(TurnAbortReason::Interrupted).await; + + // Aborting a review task should exit review mode before surfacing the abort to the client. + // We scan for these events (rather than relying on fixed ordering) since unrelated events + // may interleave. + let mut exited_review_mode_idx = None; + let mut turn_aborted_idx = None; + let mut idx = 0usize; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(3); + while tokio::time::Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let evt = tokio::time::timeout(remaining, rx.recv()) + .await + .expect("timeout waiting for event") + .expect("event"); + let event_idx = idx; + idx = idx.saturating_add(1); + match evt.msg { + EventMsg::ExitedReviewMode(ev) => { + assert!(ev.review_output.is_none()); + exited_review_mode_idx = Some(event_idx); + } + EventMsg::TurnAborted(ev) => { + assert_eq!(TurnAbortReason::Interrupted, ev.reason); + turn_aborted_idx = Some(event_idx); + break; + } + _ => {} + } + } + assert!( + exited_review_mode_idx.is_some(), + "expected ExitedReviewMode after abort" + ); + assert!( + turn_aborted_idx.is_some(), + "expected TurnAborted after abort" + ); + assert!( + exited_review_mode_idx.unwrap() < turn_aborted_idx.unwrap(), + "expected ExitedReviewMode before TurnAborted" + ); + + let history = sess.clone_history().await; + // The `` marker is silent in the event stream, so verify it is still + // recorded in history for the model. + assert!( + history.raw_items().iter().any(|item| { + let ResponseItem::Message { role, content, .. } = item else { + return false; + }; + if role != "user" { + return false; + } + content.iter().any(|content_item| { + let ContentItem::InputText { text } = content_item else { + return false; + }; + text.contains(crate::contextual_user_message::TURN_ABORTED_OPEN_TAG) + }) + }), + "expected a model-visible turn aborted marker in history after interrupt" + ); +} + +#[tokio::test] +async fn fatal_tool_error_stops_turn_and_reports_error() { + let (session, turn_context, _rx) = make_session_and_context_with_rx().await; + let tools = { + session + .services + .mcp_connection_manager + .read() + .await + .list_all_tools() + .await + }; + let app_tools = Some(tools.clone()); + let router = ToolRouter::from_config( + &turn_context.tools_config, + Some( + tools + .into_iter() + .map(|(name, tool)| (name, tool.tool)) + .collect(), + ), + app_tools, + turn_context.dynamic_tools.as_slice(), + ); + let item = ResponseItem::CustomToolCall { + id: None, + status: None, + call_id: "call-1".to_string(), + name: "shell".to_string(), + input: "{}".to_string(), + }; + + let call = ToolRouter::build_tool_call(session.as_ref(), item.clone()) + .await + .expect("build tool call") + .expect("tool call present"); + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); + let err = router + .dispatch_tool_call( + Arc::clone(&session), + Arc::clone(&turn_context), + tracker, + call, + ToolCallSource::Direct, + ) + .await + .expect_err("expected fatal error"); + + match err { + FunctionCallError::Fatal(message) => { + assert_eq!(message, "tool shell invoked with incompatible payload"); + } + other => panic!("expected FunctionCallError::Fatal, got {other:?}"), + } +} + +async fn sample_rollout( + session: &Session, + _turn_context: &TurnContext, +) -> (Vec, Vec) { + let mut rollout_items = Vec::new(); + let mut live_history = ContextManager::new(); + + // Use the same turn_context source as record_initial_history so model_info (and thus + // personality_spec) matches reconstruction. + let reconstruction_turn = session.new_default_turn().await; + let mut initial_context = session + .build_initial_context(reconstruction_turn.as_ref()) + .await; + // Ensure personality_spec is present when Personality is enabled, so expected matches + // what reconstruction produces (build_initial_context may omit it when baked into model). + if !initial_context.iter().any(|m| { + matches!(m, ResponseItem::Message { role, content, .. } + if role == "developer" + && content.iter().any(|c| { + matches!(c, ContentItem::InputText { text } if text.contains("")) + })) + }) && let Some(p) = reconstruction_turn.personality + && session.features.enabled(Feature::Personality) + && let Some(personality_message) = reconstruction_turn + .model_info + .model_messages + .as_ref() + .and_then(|m| m.get_personality_message(Some(p)).filter(|s| !s.is_empty())) + { + let msg = DeveloperInstructions::personality_spec_message(personality_message).into(); + let insert_at = initial_context + .iter() + .position(|m| matches!(m, ResponseItem::Message { role, .. } if role == "developer")) + .map(|i| i + 1) + .unwrap_or(0); + initial_context.insert(insert_at, msg); + } + for item in &initial_context { + rollout_items.push(RolloutItem::ResponseItem(item.clone())); + } + live_history.record_items( + initial_context.iter(), + reconstruction_turn.truncation_policy, + ); + + let user1 = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "first user".to_string(), + }], + end_turn: None, + phase: None, + }; + live_history.record_items( + std::iter::once(&user1), + reconstruction_turn.truncation_policy, + ); + rollout_items.push(RolloutItem::ResponseItem(user1.clone())); + + let assistant1 = ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "assistant reply one".to_string(), + }], + end_turn: None, + phase: None, + }; + live_history.record_items( + std::iter::once(&assistant1), + reconstruction_turn.truncation_policy, + ); + rollout_items.push(RolloutItem::ResponseItem(assistant1.clone())); + + let summary1 = "summary one"; + let snapshot1 = live_history + .clone() + .for_prompt(&reconstruction_turn.model_info.input_modalities); + let user_messages1 = collect_user_messages(&snapshot1); + let rebuilt1 = compact::build_compacted_history(Vec::new(), &user_messages1, summary1); + live_history.replace(rebuilt1); + rollout_items.push(RolloutItem::Compacted(CompactedItem { + message: summary1.to_string(), + replacement_history: None, + })); + + let user2 = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "second user".to_string(), + }], + end_turn: None, + phase: None, + }; + live_history.record_items( + std::iter::once(&user2), + reconstruction_turn.truncation_policy, + ); + rollout_items.push(RolloutItem::ResponseItem(user2.clone())); + + let assistant2 = ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "assistant reply two".to_string(), + }], + end_turn: None, + phase: None, + }; + live_history.record_items( + std::iter::once(&assistant2), + reconstruction_turn.truncation_policy, + ); + rollout_items.push(RolloutItem::ResponseItem(assistant2.clone())); + + let summary2 = "summary two"; + let snapshot2 = live_history + .clone() + .for_prompt(&reconstruction_turn.model_info.input_modalities); + let user_messages2 = collect_user_messages(&snapshot2); + let rebuilt2 = compact::build_compacted_history(Vec::new(), &user_messages2, summary2); + live_history.replace(rebuilt2); + rollout_items.push(RolloutItem::Compacted(CompactedItem { + message: summary2.to_string(), + replacement_history: None, + })); + + let user3 = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "third user".to_string(), + }], + end_turn: None, + phase: None, + }; + live_history.record_items( + std::iter::once(&user3), + reconstruction_turn.truncation_policy, + ); + rollout_items.push(RolloutItem::ResponseItem(user3)); + + let assistant3 = ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "assistant reply three".to_string(), + }], + end_turn: None, + phase: None, + }; + live_history.record_items( + std::iter::once(&assistant3), + reconstruction_turn.truncation_policy, + ); + rollout_items.push(RolloutItem::ResponseItem(assistant3)); + + ( + rollout_items, + live_history.for_prompt(&reconstruction_turn.model_info.input_modalities), + ) +} + +#[tokio::test] +async fn rejects_escalated_permissions_when_policy_not_on_request() { + use crate::exec::ExecParams; + use crate::protocol::AskForApproval; + use crate::protocol::SandboxPolicy; + use crate::sandboxing::SandboxPermissions; + use crate::turn_diff_tracker::TurnDiffTracker; + use std::collections::HashMap; + + let (session, mut turn_context_raw) = make_session_and_context().await; + // Ensure policy is NOT OnRequest so the early rejection path triggers + turn_context_raw + .approval_policy + .set(AskForApproval::OnFailure) + .expect("test setup should allow updating approval policy"); + let session = Arc::new(session); + let mut turn_context = Arc::new(turn_context_raw); + + let timeout_ms = 1000; + let sandbox_permissions = SandboxPermissions::RequireEscalated; + let params = ExecParams { + command: if cfg!(windows) { + vec![ + "cmd.exe".to_string(), + "/C".to_string(), + "echo hi".to_string(), + ] + } else { + vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "echo hi".to_string(), + ] + }, + cwd: turn_context.cwd.clone(), + expiration: timeout_ms.into(), + env: HashMap::new(), + network: None, + sandbox_permissions, + windows_sandbox_level: turn_context.windows_sandbox_level, + justification: Some("test".to_string()), + arg0: None, + }; + + let params2 = ExecParams { + sandbox_permissions: SandboxPermissions::UseDefault, + command: params.command.clone(), + cwd: params.cwd.clone(), + expiration: timeout_ms.into(), + env: HashMap::new(), + network: None, + windows_sandbox_level: turn_context.windows_sandbox_level, + justification: params.justification.clone(), + arg0: None, + }; + + let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); + + let tool_name = "shell"; + let call_id = "test-call".to_string(); + + let handler = ShellHandler; + let resp = handler + .handle(ToolInvocation { + session: Arc::clone(&session), + turn: Arc::clone(&turn_context), + tracker: Arc::clone(&turn_diff_tracker), + call_id, + tool_name: tool_name.to_string(), + payload: ToolPayload::Function { + arguments: serde_json::json!({ + "command": params.command.clone(), + "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), + "timeout_ms": params.expiration.timeout_ms(), + "sandbox_permissions": params.sandbox_permissions, + "justification": params.justification.clone(), + }) + .to_string(), + }, + }) + .await; + + let Err(FunctionCallError::RespondToModel(output)) = resp else { + panic!("expected error result"); + }; + + let expected = format!( + "approval policy is {policy:?}; reject command — you should not ask for escalated permissions if the approval policy is {policy:?}", + policy = turn_context.approval_policy.value() + ); + + pretty_assertions::assert_eq!(output, expected); + + // Now retry the same command WITHOUT escalated permissions; should succeed. + // Force DangerFullAccess to avoid platform sandbox dependencies in tests. + Arc::get_mut(&mut turn_context) + .expect("unique turn context Arc") + .sandbox_policy + .set(SandboxPolicy::DangerFullAccess) + .expect("test setup should allow updating sandbox policy"); + + let resp2 = handler + .handle(ToolInvocation { + session: Arc::clone(&session), + turn: Arc::clone(&turn_context), + tracker: Arc::clone(&turn_diff_tracker), + call_id: "test-call-2".to_string(), + tool_name: tool_name.to_string(), + payload: ToolPayload::Function { + arguments: serde_json::json!({ + "command": params2.command.clone(), + "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), + "timeout_ms": params2.expiration.timeout_ms(), + "sandbox_permissions": params2.sandbox_permissions, + "justification": params2.justification.clone(), + }) + .to_string(), + }, + }) + .await; + + let output = match resp2.expect("expected Ok result") { + ToolOutput::Function { + body: FunctionCallOutputBody::Text(content), + .. + } => content, + _ => panic!("unexpected tool output"), + }; + + #[derive(Deserialize, PartialEq, Eq, Debug)] + struct ResponseExecMetadata { + exit_code: i32, + } + + #[derive(Deserialize)] + struct ResponseExecOutput { + output: String, + metadata: ResponseExecMetadata, + } + + let exec_output: ResponseExecOutput = + serde_json::from_str(&output).expect("valid exec output json"); + + pretty_assertions::assert_eq!(exec_output.metadata, ResponseExecMetadata { exit_code: 0 }); + assert!(exec_output.output.contains("hi")); +} +#[tokio::test] +async fn unified_exec_rejects_escalated_permissions_when_policy_not_on_request() { + use crate::protocol::AskForApproval; + use crate::sandboxing::SandboxPermissions; + use crate::turn_diff_tracker::TurnDiffTracker; + + let (session, mut turn_context_raw) = make_session_and_context().await; + turn_context_raw + .approval_policy + .set(AskForApproval::OnFailure) + .expect("test setup should allow updating approval policy"); + let session = Arc::new(session); + let turn_context = Arc::new(turn_context_raw); + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); + + let handler = UnifiedExecHandler; + let resp = handler + .handle(ToolInvocation { + session: Arc::clone(&session), + turn: Arc::clone(&turn_context), + tracker: Arc::clone(&tracker), + call_id: "exec-call".to_string(), + tool_name: "exec_command".to_string(), + payload: ToolPayload::Function { + arguments: serde_json::json!({ + "cmd": "echo hi", + "sandbox_permissions": SandboxPermissions::RequireEscalated, + "justification": "need unsandboxed execution", + }) + .to_string(), + }, + }) + .await; + + let Err(FunctionCallError::RespondToModel(output)) = resp else { + panic!("expected error result"); + }; + + let expected = format!( + "approval policy is {policy:?}; reject command — you cannot ask for escalated permissions if the approval policy is {policy:?}", + policy = turn_context.approval_policy.value() + ); + + pretty_assertions::assert_eq!(output, expected); +} diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs new file mode 100644 index 0000000000..8fa95d6a29 --- /dev/null +++ b/codex-rs/core/src/config/config_tests.rs @@ -0,0 +1,4142 @@ +use crate::config::edit::ConfigEdit; +use crate::config::edit::ConfigEditsBuilder; +use crate::config::edit::apply_blocking; +use crate::config::types::FeedbackConfigToml; +use crate::config::types::HistoryPersistence; +use crate::config::types::McpServerTransportConfig; +use crate::config::types::MemoriesConfig; +use crate::config::types::MemoriesToml; +use crate::config::types::ModelAvailabilityNuxConfig; +use crate::config::types::NotificationMethod; +use crate::config::types::Notifications; +use crate::config_loader::RequirementSource; +use crate::features::Feature; +use assert_matches::assert_matches; +use codex_config::CONFIG_TOML_FILE; +use serde::Deserialize; +use tempfile::tempdir; + +use super::*; +use core_test_support::test_absolute_path; +use pretty_assertions::assert_eq; + +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::time::Duration; +use tempfile::TempDir; + +fn stdio_mcp(command: &str) -> McpServerConfig { + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: command.to_string(), + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: None, + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + } +} + +fn http_mcp(url: &str) -> McpServerConfig { + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: url.to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + } +} + +#[test] +fn test_toml_parsing() { + let history_with_persistence = r#" +[history] +persistence = "save-all" +"#; + let history_with_persistence_cfg = toml::from_str::(history_with_persistence) + .expect("TOML deserialization should succeed"); + assert_eq!( + Some(History { + persistence: HistoryPersistence::SaveAll, + max_bytes: None, + }), + history_with_persistence_cfg.history + ); + + let history_no_persistence = r#" +[history] +persistence = "none" +"#; + + let history_no_persistence_cfg = toml::from_str::(history_no_persistence) + .expect("TOML deserialization should succeed"); + assert_eq!( + Some(History { + persistence: HistoryPersistence::None, + max_bytes: None, + }), + history_no_persistence_cfg.history + ); + + let memories = r#" +[memories] +no_memories_if_mcp_or_web_search = true +generate_memories = false +use_memories = false +max_raw_memories_for_consolidation = 512 +max_unused_days = 21 +max_rollout_age_days = 42 +max_rollouts_per_startup = 9 +min_rollout_idle_hours = 24 +extract_model = "gpt-5-mini" +consolidation_model = "gpt-5" +"#; + let memories_cfg = + toml::from_str::(memories).expect("TOML deserialization should succeed"); + assert_eq!( + Some(MemoriesToml { + no_memories_if_mcp_or_web_search: Some(true), + generate_memories: Some(false), + use_memories: Some(false), + max_raw_memories_for_consolidation: Some(512), + max_unused_days: Some(21), + max_rollout_age_days: Some(42), + max_rollouts_per_startup: Some(9), + min_rollout_idle_hours: Some(24), + extract_model: Some("gpt-5-mini".to_string()), + consolidation_model: Some("gpt-5".to_string()), + }), + memories_cfg.memories + ); + + let config = Config::load_from_base_config_with_overrides( + memories_cfg, + ConfigOverrides::default(), + tempdir().expect("tempdir").path().to_path_buf(), + ) + .expect("load config from memories settings"); + assert_eq!( + config.memories, + MemoriesConfig { + no_memories_if_mcp_or_web_search: true, + generate_memories: false, + use_memories: false, + max_raw_memories_for_consolidation: 512, + max_unused_days: 21, + max_rollout_age_days: 42, + max_rollouts_per_startup: 9, + min_rollout_idle_hours: 24, + extract_model: Some("gpt-5-mini".to_string()), + consolidation_model: Some("gpt-5".to_string()), + } + ); +} + +#[test] +fn config_toml_deserializes_model_availability_nux() { + let toml = r#" +[tui.model_availability_nux] +"gpt-foo" = 2 +"gpt-bar" = 4 +"#; + let cfg: ConfigToml = + toml::from_str(toml).expect("TOML deserialization should succeed for TUI NUX"); + + assert_eq!( + cfg.tui.expect("tui config should deserialize"), + Tui { + notifications: Notifications::default(), + notification_method: NotificationMethod::default(), + animations: true, + show_tooltips: true, + alternate_screen: AltScreenMode::default(), + status_line: None, + theme: None, + model_availability_nux: ModelAvailabilityNuxConfig { + shown_count: HashMap::from([ + ("gpt-bar".to_string(), 4), + ("gpt-foo".to_string(), 2), + ]), + }, + } + ); +} + +#[test] +fn runtime_config_defaults_model_availability_nux() { + let cfg = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + tempdir().expect("tempdir").path().to_path_buf(), + ) + .expect("load config"); + + assert_eq!( + cfg.model_availability_nux, + ModelAvailabilityNuxConfig::default() + ); +} + +#[test] +fn config_toml_deserializes_permissions_network() { + let toml = r#" +[permissions.network] +enabled = true +proxy_url = "http://127.0.0.1:43128" +enable_socks5 = false +allow_upstream_proxy = false +allowed_domains = ["openai.com"] +"#; + let cfg: ConfigToml = + toml::from_str(toml).expect("TOML deserialization should succeed for permissions.network"); + + assert_eq!( + cfg.permissions + .and_then(|permissions| permissions.network) + .expect("permissions.network should deserialize"), + NetworkToml { + enabled: Some(true), + proxy_url: Some("http://127.0.0.1:43128".to_string()), + enable_socks5: Some(false), + socks_url: None, + enable_socks5_udp: None, + allow_upstream_proxy: Some(false), + dangerously_allow_non_loopback_proxy: None, + dangerously_allow_all_unix_sockets: None, + mode: None, + allowed_domains: Some(vec!["openai.com".to_string()]), + denied_domains: None, + allow_unix_sockets: None, + allow_local_binding: None, + } + ); +} + +#[test] +fn permissions_network_enabled_populates_runtime_network_proxy_spec() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cfg = ConfigToml { + permissions: Some(PermissionsToml { + network: Some(NetworkToml { + enabled: Some(true), + proxy_url: Some("http://127.0.0.1:43128".to_string()), + enable_socks5: Some(false), + ..Default::default() + }), + }), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + let network = config + .permissions + .network + .as_ref() + .expect("enabled permissions.network should produce a NetworkProxySpec"); + + assert_eq!(network.proxy_host_and_port(), "127.0.0.1:43128"); + assert!(!network.socks_enabled()); + Ok(()) +} + +#[test] +fn permissions_network_disabled_by_default_does_not_start_proxy() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cfg = ConfigToml { + permissions: Some(PermissionsToml { + network: Some(NetworkToml { + allowed_domains: Some(vec!["openai.com".to_string()]), + ..Default::default() + }), + }), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + assert!(config.permissions.network.is_none()); + Ok(()) +} + +#[test] +fn tui_theme_deserializes_from_toml() { + let cfg = r#" +[tui] +theme = "dracula" +"#; + let parsed = toml::from_str::(cfg).expect("TOML deserialization should succeed"); + assert_eq!( + parsed.tui.as_ref().and_then(|t| t.theme.as_deref()), + Some("dracula"), + ); +} + +#[test] +fn tui_theme_defaults_to_none() { + let cfg = r#" +[tui] +"#; + let parsed = toml::from_str::(cfg).expect("TOML deserialization should succeed"); + assert_eq!(parsed.tui.as_ref().and_then(|t| t.theme.as_deref()), None); +} + +#[test] +fn tui_config_missing_notifications_field_defaults_to_enabled() { + let cfg = r#" +[tui] +"#; + + let parsed = + toml::from_str::(cfg).expect("TUI config without notifications should succeed"); + let tui = parsed.tui.expect("config should include tui section"); + + assert_eq!( + tui, + Tui { + notifications: Notifications::Enabled(true), + notification_method: NotificationMethod::Auto, + animations: true, + show_tooltips: true, + alternate_screen: AltScreenMode::Auto, + status_line: None, + theme: None, + model_availability_nux: ModelAvailabilityNuxConfig::default(), + } + ); +} + +#[test] +fn test_sandbox_config_parsing() { + let sandbox_full_access = r#" +sandbox_mode = "danger-full-access" + +[sandbox_workspace_write] +network_access = false # This should be ignored. +"#; + let sandbox_full_access_cfg = toml::from_str::(sandbox_full_access) + .expect("TOML deserialization should succeed"); + let sandbox_mode_override = None; + let resolution = sandbox_full_access_cfg.derive_sandbox_policy( + sandbox_mode_override, + None, + WindowsSandboxLevel::Disabled, + &PathBuf::from("/tmp/test"), + None, + ); + assert_eq!(resolution, SandboxPolicy::DangerFullAccess); + + let sandbox_read_only = r#" +sandbox_mode = "read-only" + +[sandbox_workspace_write] +network_access = true # This should be ignored. +"#; + + let sandbox_read_only_cfg = toml::from_str::(sandbox_read_only) + .expect("TOML deserialization should succeed"); + let sandbox_mode_override = None; + let resolution = sandbox_read_only_cfg.derive_sandbox_policy( + sandbox_mode_override, + None, + WindowsSandboxLevel::Disabled, + &PathBuf::from("/tmp/test"), + None, + ); + assert_eq!(resolution, SandboxPolicy::new_read_only_policy()); + + let writable_root = test_absolute_path("/my/workspace"); + let sandbox_workspace_write = format!( + r#" +sandbox_mode = "workspace-write" + +[sandbox_workspace_write] +writable_roots = [ + {}, +] +exclude_tmpdir_env_var = true +exclude_slash_tmp = true +"#, + serde_json::json!(writable_root) + ); + + let sandbox_workspace_write_cfg = toml::from_str::(&sandbox_workspace_write) + .expect("TOML deserialization should succeed"); + let sandbox_mode_override = None; + let resolution = sandbox_workspace_write_cfg.derive_sandbox_policy( + sandbox_mode_override, + None, + WindowsSandboxLevel::Disabled, + &PathBuf::from("/tmp/test"), + None, + ); + if cfg!(target_os = "windows") { + assert_eq!(resolution, SandboxPolicy::new_read_only_policy()); + } else { + assert_eq!( + resolution, + SandboxPolicy::WorkspaceWrite { + writable_roots: vec![writable_root.clone()], + read_only_access: ReadOnlyAccess::FullAccess, + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + } + ); + } + + let sandbox_workspace_write = format!( + r#" +sandbox_mode = "workspace-write" + +[sandbox_workspace_write] +writable_roots = [ + {}, +] +exclude_tmpdir_env_var = true +exclude_slash_tmp = true + +[projects."/tmp/test"] +trust_level = "trusted" +"#, + serde_json::json!(writable_root) + ); + + let sandbox_workspace_write_cfg = toml::from_str::(&sandbox_workspace_write) + .expect("TOML deserialization should succeed"); + let sandbox_mode_override = None; + let resolution = sandbox_workspace_write_cfg.derive_sandbox_policy( + sandbox_mode_override, + None, + WindowsSandboxLevel::Disabled, + &PathBuf::from("/tmp/test"), + None, + ); + if cfg!(target_os = "windows") { + assert_eq!(resolution, SandboxPolicy::new_read_only_policy()); + } else { + assert_eq!( + resolution, + SandboxPolicy::WorkspaceWrite { + writable_roots: vec![writable_root], + read_only_access: ReadOnlyAccess::FullAccess, + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + } + ); + } +} + +#[test] +fn filter_mcp_servers_by_allowlist_enforces_identity_rules() { + const MISMATCHED_COMMAND_SERVER: &str = "mismatched-command-should-disable"; + const MISMATCHED_URL_SERVER: &str = "mismatched-url-should-disable"; + const MATCHED_COMMAND_SERVER: &str = "matched-command-should-allow"; + const MATCHED_URL_SERVER: &str = "matched-url-should-allow"; + const DIFFERENT_NAME_SERVER: &str = "different-name-should-disable"; + + const GOOD_CMD: &str = "good-cmd"; + const GOOD_URL: &str = "https://example.com/good"; + + let mut servers = HashMap::from([ + (MISMATCHED_COMMAND_SERVER.to_string(), stdio_mcp("docs-cmd")), + ( + MISMATCHED_URL_SERVER.to_string(), + http_mcp("https://example.com/mcp"), + ), + (MATCHED_COMMAND_SERVER.to_string(), stdio_mcp(GOOD_CMD)), + (MATCHED_URL_SERVER.to_string(), http_mcp(GOOD_URL)), + (DIFFERENT_NAME_SERVER.to_string(), stdio_mcp("same-cmd")), + ]); + let source = RequirementSource::LegacyManagedConfigTomlFromMdm; + let requirements = Sourced::new( + BTreeMap::from([ + ( + MISMATCHED_URL_SERVER.to_string(), + McpServerRequirement { + identity: McpServerIdentity::Url { + url: "https://example.com/other".to_string(), + }, + }, + ), + ( + MISMATCHED_COMMAND_SERVER.to_string(), + McpServerRequirement { + identity: McpServerIdentity::Command { + command: "other-cmd".to_string(), + }, + }, + ), + ( + MATCHED_URL_SERVER.to_string(), + McpServerRequirement { + identity: McpServerIdentity::Url { + url: GOOD_URL.to_string(), + }, + }, + ), + ( + MATCHED_COMMAND_SERVER.to_string(), + McpServerRequirement { + identity: McpServerIdentity::Command { + command: GOOD_CMD.to_string(), + }, + }, + ), + ]), + source.clone(), + ); + filter_mcp_servers_by_requirements(&mut servers, Some(&requirements)); + + let reason = Some(McpServerDisabledReason::Requirements { source }); + assert_eq!( + servers + .iter() + .map(|(name, server)| ( + name.clone(), + (server.enabled, server.disabled_reason.clone()) + )) + .collect::)>>(), + HashMap::from([ + (MISMATCHED_URL_SERVER.to_string(), (false, reason.clone())), + ( + MISMATCHED_COMMAND_SERVER.to_string(), + (false, reason.clone()), + ), + (MATCHED_URL_SERVER.to_string(), (true, None)), + (MATCHED_COMMAND_SERVER.to_string(), (true, None)), + (DIFFERENT_NAME_SERVER.to_string(), (false, reason)), + ]) + ); +} + +#[test] +fn filter_mcp_servers_by_allowlist_allows_all_when_unset() { + let mut servers = HashMap::from([ + ("server-a".to_string(), stdio_mcp("cmd-a")), + ("server-b".to_string(), http_mcp("https://example.com/b")), + ]); + + filter_mcp_servers_by_requirements(&mut servers, None); + + assert_eq!( + servers + .iter() + .map(|(name, server)| ( + name.clone(), + (server.enabled, server.disabled_reason.clone()) + )) + .collect::)>>(), + HashMap::from([ + ("server-a".to_string(), (true, None)), + ("server-b".to_string(), (true, None)), + ]) + ); +} + +#[test] +fn filter_mcp_servers_by_allowlist_blocks_all_when_empty() { + let mut servers = HashMap::from([ + ("server-a".to_string(), stdio_mcp("cmd-a")), + ("server-b".to_string(), http_mcp("https://example.com/b")), + ]); + + let source = RequirementSource::LegacyManagedConfigTomlFromMdm; + let requirements = Sourced::new(BTreeMap::new(), source.clone()); + filter_mcp_servers_by_requirements(&mut servers, Some(&requirements)); + + let reason = Some(McpServerDisabledReason::Requirements { source }); + assert_eq!( + servers + .iter() + .map(|(name, server)| ( + name.clone(), + (server.enabled, server.disabled_reason.clone()) + )) + .collect::)>>(), + HashMap::from([ + ("server-a".to_string(), (false, reason.clone())), + ("server-b".to_string(), (false, reason)), + ]) + ); +} + +#[test] +fn add_dir_override_extends_workspace_writable_roots() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let frontend = temp_dir.path().join("frontend"); + let backend = temp_dir.path().join("backend"); + std::fs::create_dir_all(&frontend)?; + std::fs::create_dir_all(&backend)?; + + let overrides = ConfigOverrides { + cwd: Some(frontend), + sandbox_mode: Some(SandboxMode::WorkspaceWrite), + additional_writable_roots: vec![PathBuf::from("../backend"), backend.clone()], + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + overrides, + temp_dir.path().to_path_buf(), + )?; + + let expected_backend = AbsolutePathBuf::try_from(backend).unwrap(); + if cfg!(target_os = "windows") { + match config.permissions.sandbox_policy.get() { + SandboxPolicy::ReadOnly { .. } => {} + other => panic!("expected read-only policy on Windows, got {other:?}"), + } + } else { + match config.permissions.sandbox_policy.get() { + SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { + assert_eq!( + writable_roots + .iter() + .filter(|root| **root == expected_backend) + .count(), + 1, + "expected single writable root entry for {}", + expected_backend.display() + ); + } + other => panic!("expected workspace-write policy, got {other:?}"), + } + } + + Ok(()) +} + +#[test] +fn sqlite_home_defaults_to_codex_home_for_workspace_write() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides { + sandbox_mode: Some(SandboxMode::WorkspaceWrite), + ..Default::default() + }, + codex_home.path().to_path_buf(), + )?; + + assert_eq!(config.sqlite_home, codex_home.path().to_path_buf()); + + Ok(()) +} + +#[test] +fn workspace_write_always_includes_memories_root_once() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let memories_root = codex_home.path().join("memories"); + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + sandbox_workspace_write: Some(SandboxWorkspaceWrite { + writable_roots: vec![AbsolutePathBuf::from_absolute_path(&memories_root)?], + ..Default::default() + }), + ..Default::default() + }, + ConfigOverrides { + sandbox_mode: Some(SandboxMode::WorkspaceWrite), + ..Default::default() + }, + codex_home.path().to_path_buf(), + )?; + + if cfg!(target_os = "windows") { + match config.permissions.sandbox_policy.get() { + SandboxPolicy::ReadOnly { .. } => {} + other => panic!("expected read-only policy on Windows, got {other:?}"), + } + } else { + assert!( + memories_root.is_dir(), + "expected memories root directory to exist at {}", + memories_root.display() + ); + let expected_memories_root = AbsolutePathBuf::from_absolute_path(&memories_root)?; + match config.permissions.sandbox_policy.get() { + SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { + assert_eq!( + writable_roots + .iter() + .filter(|root| **root == expected_memories_root) + .count(), + 1, + "expected single writable root entry for {}", + expected_memories_root.display() + ); + } + other => panic!("expected workspace-write policy, got {other:?}"), + } + } + + Ok(()) +} + +#[test] +fn config_defaults_to_file_cli_auth_store_mode() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cfg = ConfigToml::default(); + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert_eq!( + config.cli_auth_credentials_store_mode, + AuthCredentialsStoreMode::File, + ); + + Ok(()) +} + +#[test] +fn config_honors_explicit_keyring_auth_store_mode() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cfg = ConfigToml { + cli_auth_credentials_store: Some(AuthCredentialsStoreMode::Keyring), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert_eq!( + config.cli_auth_credentials_store_mode, + AuthCredentialsStoreMode::Keyring, + ); + + Ok(()) +} + +#[test] +fn config_defaults_to_auto_oauth_store_mode() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cfg = ConfigToml::default(); + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert_eq!( + config.mcp_oauth_credentials_store_mode, + OAuthCredentialsStoreMode::Auto, + ); + + Ok(()) +} + +#[test] +fn feedback_enabled_defaults_to_true() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cfg = ConfigToml { + feedback: Some(FeedbackConfigToml::default()), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert_eq!(config.feedback_enabled, true); + + Ok(()) +} + +#[test] +fn web_search_mode_defaults_to_none_if_unset() { + let cfg = ConfigToml::default(); + let profile = ConfigProfile::default(); + let features = Features::with_defaults(); + + assert_eq!(resolve_web_search_mode(&cfg, &profile, &features), None); +} + +#[test] +fn web_search_mode_prefers_profile_over_legacy_flags() { + let cfg = ConfigToml::default(); + let profile = ConfigProfile { + web_search: Some(WebSearchMode::Live), + ..Default::default() + }; + let mut features = Features::with_defaults(); + features.enable(Feature::WebSearchCached); + + assert_eq!( + resolve_web_search_mode(&cfg, &profile, &features), + Some(WebSearchMode::Live) + ); +} + +#[test] +fn web_search_mode_disabled_overrides_legacy_request() { + let cfg = ConfigToml { + web_search: Some(WebSearchMode::Disabled), + ..Default::default() + }; + let profile = ConfigProfile::default(); + let mut features = Features::with_defaults(); + features.enable(Feature::WebSearchRequest); + + assert_eq!( + resolve_web_search_mode(&cfg, &profile, &features), + Some(WebSearchMode::Disabled) + ); +} + +#[test] +fn web_search_mode_for_turn_uses_preference_for_read_only() { + let web_search_mode = Constrained::allow_any(WebSearchMode::Cached); + let mode = + resolve_web_search_mode_for_turn(&web_search_mode, &SandboxPolicy::new_read_only_policy()); + + assert_eq!(mode, WebSearchMode::Cached); +} + +#[test] +fn web_search_mode_for_turn_prefers_live_for_danger_full_access() { + let web_search_mode = Constrained::allow_any(WebSearchMode::Cached); + let mode = resolve_web_search_mode_for_turn(&web_search_mode, &SandboxPolicy::DangerFullAccess); + + assert_eq!(mode, WebSearchMode::Live); +} + +#[test] +fn web_search_mode_for_turn_respects_disabled_for_danger_full_access() { + let web_search_mode = Constrained::allow_any(WebSearchMode::Disabled); + let mode = resolve_web_search_mode_for_turn(&web_search_mode, &SandboxPolicy::DangerFullAccess); + + assert_eq!(mode, WebSearchMode::Disabled); +} + +#[test] +fn web_search_mode_for_turn_falls_back_when_live_is_disallowed() -> anyhow::Result<()> { + let allowed = [WebSearchMode::Disabled, WebSearchMode::Cached]; + let web_search_mode = Constrained::new(WebSearchMode::Cached, move |candidate| { + if allowed.contains(candidate) { + Ok(()) + } else { + Err(ConstraintError::InvalidValue { + field_name: "web_search_mode", + candidate: format!("{candidate:?}"), + allowed: format!("{allowed:?}"), + requirement_source: RequirementSource::Unknown, + }) + } + })?; + let mode = resolve_web_search_mode_for_turn(&web_search_mode, &SandboxPolicy::DangerFullAccess); + + assert_eq!(mode, WebSearchMode::Cached); + Ok(()) +} + +#[test] +fn profile_legacy_toggles_override_base() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let mut profiles = HashMap::new(); + profiles.insert( + "work".to_string(), + ConfigProfile { + tools_web_search: Some(false), + ..Default::default() + }, + ); + let cfg = ConfigToml { + profiles, + profile: Some("work".to_string()), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert!(!config.features.enabled(Feature::WebSearchRequest)); + + Ok(()) +} + +#[tokio::test] +async fn project_profile_overrides_user_profile() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let workspace = TempDir::new()?; + let workspace_key = workspace.path().to_string_lossy().replace('\\', "\\\\"); + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + format!( + r#" +profile = "global" + +[profiles.global] +model = "gpt-global" + +[profiles.project] +model = "gpt-project" + +[projects."{workspace_key}"] +trust_level = "trusted" +"#, + ), + )?; + let project_config_dir = workspace.path().join(".codex"); + std::fs::create_dir_all(&project_config_dir)?; + std::fs::write( + project_config_dir.join(CONFIG_TOML_FILE), + r#" +profile = "project" +"#, + )?; + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + cwd: Some(workspace.path().to_path_buf()), + ..Default::default() + }) + .build() + .await?; + + assert_eq!(config.active_profile.as_deref(), Some("project")); + assert_eq!(config.model.as_deref(), Some("gpt-project")); + + Ok(()) +} + +#[test] +fn profile_sandbox_mode_overrides_base() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let mut profiles = HashMap::new(); + profiles.insert( + "work".to_string(), + ConfigProfile { + sandbox_mode: Some(SandboxMode::DangerFullAccess), + ..Default::default() + }, + ); + let cfg = ConfigToml { + profiles, + profile: Some("work".to_string()), + sandbox_mode: Some(SandboxMode::ReadOnly), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert!(matches!( + config.permissions.sandbox_policy.get(), + &SandboxPolicy::DangerFullAccess + )); + + Ok(()) +} + +#[test] +fn cli_override_takes_precedence_over_profile_sandbox_mode() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let mut profiles = HashMap::new(); + profiles.insert( + "work".to_string(), + ConfigProfile { + sandbox_mode: Some(SandboxMode::DangerFullAccess), + ..Default::default() + }, + ); + let cfg = ConfigToml { + profiles, + profile: Some("work".to_string()), + ..Default::default() + }; + + let overrides = ConfigOverrides { + sandbox_mode: Some(SandboxMode::WorkspaceWrite), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + cfg, + overrides, + codex_home.path().to_path_buf(), + )?; + + if cfg!(target_os = "windows") { + assert!(matches!( + config.permissions.sandbox_policy.get(), + SandboxPolicy::ReadOnly { .. } + )); + } else { + assert!(matches!( + config.permissions.sandbox_policy.get(), + SandboxPolicy::WorkspaceWrite { .. } + )); + } + + Ok(()) +} + +#[test] +fn feature_table_overrides_legacy_flags() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let mut entries = BTreeMap::new(); + entries.insert("apply_patch_freeform".to_string(), false); + let cfg = ConfigToml { + features: Some(crate::features::FeaturesToml { entries }), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert!(!config.features.enabled(Feature::ApplyPatchFreeform)); + assert!(!config.include_apply_patch_tool); + + Ok(()) +} + +#[test] +fn legacy_toggles_map_to_features() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cfg = ConfigToml { + experimental_use_unified_exec_tool: Some(true), + experimental_use_freeform_apply_patch: Some(true), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert!(config.features.enabled(Feature::ApplyPatchFreeform)); + assert!(config.features.enabled(Feature::UnifiedExec)); + + assert!(config.include_apply_patch_tool); + + assert!(config.use_experimental_unified_exec_tool); + + Ok(()) +} + +#[test] +fn responses_websocket_features_do_not_change_wire_api() -> std::io::Result<()> { + for feature_key in ["responses_websockets", "responses_websockets_v2"] { + let codex_home = TempDir::new()?; + let mut entries = BTreeMap::new(); + entries.insert(feature_key.to_string(), true); + let cfg = ConfigToml { + features: Some(crate::features::FeaturesToml { entries }), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert_eq!( + config.model_provider.wire_api, + crate::model_provider_info::WireApi::Responses + ); + } + + Ok(()) +} + +#[test] +fn config_honors_explicit_file_oauth_store_mode() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cfg = ConfigToml { + mcp_oauth_credentials_store: Some(OAuthCredentialsStoreMode::File), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert_eq!( + config.mcp_oauth_credentials_store_mode, + OAuthCredentialsStoreMode::File, + ); + + Ok(()) +} + +#[tokio::test] +async fn managed_config_overrides_oauth_store_mode() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let managed_path = codex_home.path().join("managed_config.toml"); + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + + std::fs::write(&config_path, "mcp_oauth_credentials_store = \"file\"\n")?; + std::fs::write(&managed_path, "mcp_oauth_credentials_store = \"keyring\"\n")?; + + let overrides = LoaderOverrides { + managed_config_path: Some(managed_path.clone()), + #[cfg(target_os = "macos")] + managed_preferences_base64: None, + macos_managed_config_requirements_base64: None, + }; + + let cwd = AbsolutePathBuf::try_from(codex_home.path())?; + let config_layer_stack = load_config_layers_state( + codex_home.path(), + Some(cwd), + &Vec::new(), + overrides, + CloudRequirementsLoader::default(), + ) + .await?; + let cfg = + deserialize_config_toml_with_base(config_layer_stack.effective_config(), codex_home.path()) + .map_err(|e| { + tracing::error!("Failed to deserialize overridden config: {e}"); + e + })?; + assert_eq!( + cfg.mcp_oauth_credentials_store, + Some(OAuthCredentialsStoreMode::Keyring), + ); + + let final_config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + assert_eq!( + final_config.mcp_oauth_credentials_store_mode, + OAuthCredentialsStoreMode::Keyring, + ); + + Ok(()) +} + +#[tokio::test] +async fn load_global_mcp_servers_returns_empty_if_missing() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + let servers = load_global_mcp_servers(codex_home.path()).await?; + assert!(servers.is_empty()); + + Ok(()) +} + +#[tokio::test] +async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + let mut servers = BTreeMap::new(); + servers.insert( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec!["hello".to_string()], + env: None, + env_vars: Vec::new(), + cwd: None, + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: Some(Duration::from_secs(3)), + tool_timeout_sec: Some(Duration::from_secs(5)), + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + }, + ); + + apply_blocking( + codex_home.path(), + None, + &[ConfigEdit::ReplaceMcpServers(servers.clone())], + )?; + + let loaded = load_global_mcp_servers(codex_home.path()).await?; + assert_eq!(loaded.len(), 1); + let docs = loaded.get("docs").expect("docs entry"); + match &docs.transport { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { + assert_eq!(command, "echo"); + assert_eq!(args, &vec!["hello".to_string()]); + assert!(env.is_none()); + assert!(env_vars.is_empty()); + assert!(cwd.is_none()); + } + other => panic!("unexpected transport {other:?}"), + } + assert_eq!(docs.startup_timeout_sec, Some(Duration::from_secs(3))); + assert_eq!(docs.tool_timeout_sec, Some(Duration::from_secs(5))); + assert!(docs.enabled); + + let empty = BTreeMap::new(); + apply_blocking( + codex_home.path(), + None, + &[ConfigEdit::ReplaceMcpServers(empty.clone())], + )?; + let loaded = load_global_mcp_servers(codex_home.path()).await?; + assert!(loaded.is_empty()); + + Ok(()) +} + +#[tokio::test] +async fn managed_config_wins_over_cli_overrides() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let managed_path = codex_home.path().join("managed_config.toml"); + + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + "model = \"base\"\n", + )?; + std::fs::write(&managed_path, "model = \"managed_config\"\n")?; + + let overrides = LoaderOverrides { + managed_config_path: Some(managed_path), + #[cfg(target_os = "macos")] + managed_preferences_base64: None, + macos_managed_config_requirements_base64: None, + }; + + let cwd = AbsolutePathBuf::try_from(codex_home.path())?; + let config_layer_stack = load_config_layers_state( + codex_home.path(), + Some(cwd), + &[("model".to_string(), TomlValue::String("cli".to_string()))], + overrides, + CloudRequirementsLoader::default(), + ) + .await?; + + let cfg = + deserialize_config_toml_with_base(config_layer_stack.effective_config(), codex_home.path()) + .map_err(|e| { + tracing::error!("Failed to deserialize overridden config: {e}"); + e + })?; + + assert_eq!(cfg.model.as_deref(), Some("managed_config")); + Ok(()) +} + +#[tokio::test] +async fn load_global_mcp_servers_accepts_legacy_ms_field() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + + std::fs::write( + &config_path, + r#" +[mcp_servers] +[mcp_servers.docs] +command = "echo" +startup_timeout_ms = 2500 +"#, + )?; + + let servers = load_global_mcp_servers(codex_home.path()).await?; + let docs = servers.get("docs").expect("docs entry"); + assert_eq!(docs.startup_timeout_sec, Some(Duration::from_millis(2500))); + + Ok(()) +} + +#[tokio::test] +async fn load_global_mcp_servers_rejects_inline_bearer_token() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + + std::fs::write( + &config_path, + r#" +[mcp_servers.docs] +url = "https://example.com/mcp" +bearer_token = "secret" +"#, + )?; + + let err = load_global_mcp_servers(codex_home.path()) + .await + .expect_err("bearer_token entries should be rejected"); + + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); + assert!(err.to_string().contains("bearer_token")); + assert!(err.to_string().contains("bearer_token_env_var")); + + Ok(()) +} + +#[tokio::test] +async fn replace_mcp_servers_serializes_env_sorted() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + let servers = BTreeMap::from([( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "docs-server".to_string(), + args: vec!["--verbose".to_string()], + env: Some(HashMap::from([ + ("ZIG_VAR".to_string(), "3".to_string()), + ("ALPHA_VAR".to_string(), "1".to_string()), + ])), + env_vars: Vec::new(), + cwd: None, + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + }, + )]); + + apply_blocking( + codex_home.path(), + None, + &[ConfigEdit::ReplaceMcpServers(servers.clone())], + )?; + + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + let serialized = std::fs::read_to_string(&config_path)?; + assert_eq!( + serialized, + r#"[mcp_servers.docs] +command = "docs-server" +args = ["--verbose"] + +[mcp_servers.docs.env] +ALPHA_VAR = "1" +ZIG_VAR = "3" +"# + ); + + let loaded = load_global_mcp_servers(codex_home.path()).await?; + let docs = loaded.get("docs").expect("docs entry"); + match &docs.transport { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { + assert_eq!(command, "docs-server"); + assert_eq!(args, &vec!["--verbose".to_string()]); + let env = env + .as_ref() + .expect("env should be preserved for stdio transport"); + assert_eq!(env.get("ALPHA_VAR"), Some(&"1".to_string())); + assert_eq!(env.get("ZIG_VAR"), Some(&"3".to_string())); + assert!(env_vars.is_empty()); + assert!(cwd.is_none()); + } + other => panic!("unexpected transport {other:?}"), + } + + Ok(()) +} + +#[tokio::test] +async fn replace_mcp_servers_serializes_env_vars() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + let servers = BTreeMap::from([( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "docs-server".to_string(), + args: Vec::new(), + env: None, + env_vars: vec!["ALPHA".to_string(), "BETA".to_string()], + cwd: None, + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + }, + )]); + + apply_blocking( + codex_home.path(), + None, + &[ConfigEdit::ReplaceMcpServers(servers.clone())], + )?; + + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + let serialized = std::fs::read_to_string(&config_path)?; + assert!( + serialized.contains(r#"env_vars = ["ALPHA", "BETA"]"#), + "serialized config missing env_vars field:\n{serialized}" + ); + + let loaded = load_global_mcp_servers(codex_home.path()).await?; + let docs = loaded.get("docs").expect("docs entry"); + match &docs.transport { + McpServerTransportConfig::Stdio { env_vars, .. } => { + assert_eq!(env_vars, &vec!["ALPHA".to_string(), "BETA".to_string()]); + } + other => panic!("unexpected transport {other:?}"), + } + + Ok(()) +} + +#[tokio::test] +async fn replace_mcp_servers_serializes_cwd() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + let cwd_path = PathBuf::from("/tmp/codex-mcp"); + let servers = BTreeMap::from([( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "docs-server".to_string(), + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: Some(cwd_path.clone()), + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + }, + )]); + + apply_blocking( + codex_home.path(), + None, + &[ConfigEdit::ReplaceMcpServers(servers.clone())], + )?; + + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + let serialized = std::fs::read_to_string(&config_path)?; + assert!( + serialized.contains(r#"cwd = "/tmp/codex-mcp""#), + "serialized config missing cwd field:\n{serialized}" + ); + + let loaded = load_global_mcp_servers(codex_home.path()).await?; + let docs = loaded.get("docs").expect("docs entry"); + match &docs.transport { + McpServerTransportConfig::Stdio { cwd, .. } => { + assert_eq!(cwd.as_deref(), Some(Path::new("/tmp/codex-mcp"))); + } + other => panic!("unexpected transport {other:?}"), + } + + Ok(()) +} + +#[tokio::test] +async fn replace_mcp_servers_streamable_http_serializes_bearer_token() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + let servers = BTreeMap::from([( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".to_string(), + bearer_token_env_var: Some("MCP_TOKEN".to_string()), + http_headers: None, + env_http_headers: None, + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: Some(Duration::from_secs(2)), + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + }, + )]); + + apply_blocking( + codex_home.path(), + None, + &[ConfigEdit::ReplaceMcpServers(servers.clone())], + )?; + + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + let serialized = std::fs::read_to_string(&config_path)?; + assert_eq!( + serialized, + r#"[mcp_servers.docs] +url = "https://example.com/mcp" +bearer_token_env_var = "MCP_TOKEN" +startup_timeout_sec = 2.0 +"# + ); + + let loaded = load_global_mcp_servers(codex_home.path()).await?; + let docs = loaded.get("docs").expect("docs entry"); + match &docs.transport { + McpServerTransportConfig::StreamableHttp { + url, + bearer_token_env_var, + http_headers, + env_http_headers, + } => { + assert_eq!(url, "https://example.com/mcp"); + assert_eq!(bearer_token_env_var.as_deref(), Some("MCP_TOKEN")); + assert!(http_headers.is_none()); + assert!(env_http_headers.is_none()); + } + other => panic!("unexpected transport {other:?}"), + } + assert_eq!(docs.startup_timeout_sec, Some(Duration::from_secs(2))); + + Ok(()) +} + +#[tokio::test] +async fn replace_mcp_servers_streamable_http_serializes_custom_headers() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + let servers = BTreeMap::from([( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".to_string(), + bearer_token_env_var: Some("MCP_TOKEN".to_string()), + http_headers: Some(HashMap::from([("X-Doc".to_string(), "42".to_string())])), + env_http_headers: Some(HashMap::from([( + "X-Auth".to_string(), + "DOCS_AUTH".to_string(), + )])), + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: Some(Duration::from_secs(2)), + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + }, + )]); + apply_blocking( + codex_home.path(), + None, + &[ConfigEdit::ReplaceMcpServers(servers.clone())], + )?; + + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + let serialized = std::fs::read_to_string(&config_path)?; + assert_eq!( + serialized, + r#"[mcp_servers.docs] +url = "https://example.com/mcp" +bearer_token_env_var = "MCP_TOKEN" +startup_timeout_sec = 2.0 + +[mcp_servers.docs.http_headers] +X-Doc = "42" + +[mcp_servers.docs.env_http_headers] +X-Auth = "DOCS_AUTH" +"# + ); + + let loaded = load_global_mcp_servers(codex_home.path()).await?; + let docs = loaded.get("docs").expect("docs entry"); + match &docs.transport { + McpServerTransportConfig::StreamableHttp { + http_headers, + env_http_headers, + .. + } => { + assert_eq!( + http_headers, + &Some(HashMap::from([("X-Doc".to_string(), "42".to_string())])) + ); + assert_eq!( + env_http_headers, + &Some(HashMap::from([( + "X-Auth".to_string(), + "DOCS_AUTH".to_string() + )])) + ); + } + other => panic!("unexpected transport {other:?}"), + } + + Ok(()) +} + +#[tokio::test] +async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + + let mut servers = BTreeMap::from([( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".to_string(), + bearer_token_env_var: Some("MCP_TOKEN".to_string()), + http_headers: Some(HashMap::from([("X-Doc".to_string(), "42".to_string())])), + env_http_headers: Some(HashMap::from([( + "X-Auth".to_string(), + "DOCS_AUTH".to_string(), + )])), + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: Some(Duration::from_secs(2)), + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + }, + )]); + + apply_blocking( + codex_home.path(), + None, + &[ConfigEdit::ReplaceMcpServers(servers.clone())], + )?; + let serialized_with_optional = std::fs::read_to_string(&config_path)?; + assert!(serialized_with_optional.contains("bearer_token_env_var = \"MCP_TOKEN\"")); + assert!(serialized_with_optional.contains("[mcp_servers.docs.http_headers]")); + assert!(serialized_with_optional.contains("[mcp_servers.docs.env_http_headers]")); + + servers.insert( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + }, + ); + apply_blocking( + codex_home.path(), + None, + &[ConfigEdit::ReplaceMcpServers(servers.clone())], + )?; + + let serialized = std::fs::read_to_string(&config_path)?; + assert_eq!( + serialized, + r#"[mcp_servers.docs] +url = "https://example.com/mcp" +"# + ); + + let loaded = load_global_mcp_servers(codex_home.path()).await?; + let docs = loaded.get("docs").expect("docs entry"); + match &docs.transport { + McpServerTransportConfig::StreamableHttp { + url, + bearer_token_env_var, + http_headers, + env_http_headers, + } => { + assert_eq!(url, "https://example.com/mcp"); + assert!(bearer_token_env_var.is_none()); + assert!(http_headers.is_none()); + assert!(env_http_headers.is_none()); + } + other => panic!("unexpected transport {other:?}"), + } + + assert!(docs.startup_timeout_sec.is_none()); + + Ok(()) +} + +#[tokio::test] +async fn replace_mcp_servers_streamable_http_isolates_headers_between_servers() -> anyhow::Result<()> +{ + let codex_home = TempDir::new()?; + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + + let servers = BTreeMap::from([ + ( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".to_string(), + bearer_token_env_var: Some("MCP_TOKEN".to_string()), + http_headers: Some(HashMap::from([("X-Doc".to_string(), "42".to_string())])), + env_http_headers: Some(HashMap::from([( + "X-Auth".to_string(), + "DOCS_AUTH".to_string(), + )])), + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: Some(Duration::from_secs(2)), + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + }, + ), + ( + "logs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "logs-server".to_string(), + args: vec!["--follow".to_string()], + env: None, + env_vars: Vec::new(), + cwd: None, + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + }, + ), + ]); + + apply_blocking( + codex_home.path(), + None, + &[ConfigEdit::ReplaceMcpServers(servers.clone())], + )?; + + let serialized = std::fs::read_to_string(&config_path)?; + assert!( + serialized.contains("[mcp_servers.docs.http_headers]"), + "serialized config missing docs headers section:\n{serialized}" + ); + assert!( + !serialized.contains("[mcp_servers.logs.http_headers]"), + "serialized config should not add logs headers section:\n{serialized}" + ); + assert!( + !serialized.contains("[mcp_servers.logs.env_http_headers]"), + "serialized config should not add logs env headers section:\n{serialized}" + ); + assert!( + !serialized.contains("mcp_servers.logs.bearer_token_env_var"), + "serialized config should not add bearer token to logs:\n{serialized}" + ); + + let loaded = load_global_mcp_servers(codex_home.path()).await?; + let docs = loaded.get("docs").expect("docs entry"); + match &docs.transport { + McpServerTransportConfig::StreamableHttp { + http_headers, + env_http_headers, + .. + } => { + assert_eq!( + http_headers, + &Some(HashMap::from([("X-Doc".to_string(), "42".to_string())])) + ); + assert_eq!( + env_http_headers, + &Some(HashMap::from([( + "X-Auth".to_string(), + "DOCS_AUTH".to_string() + )])) + ); + } + other => panic!("unexpected transport {other:?}"), + } + let logs = loaded.get("logs").expect("logs entry"); + match &logs.transport { + McpServerTransportConfig::Stdio { env, .. } => { + assert!(env.is_none()); + } + other => panic!("unexpected transport {other:?}"), + } + + Ok(()) +} + +#[tokio::test] +async fn replace_mcp_servers_serializes_disabled_flag() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + let servers = BTreeMap::from([( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "docs-server".to_string(), + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: None, + }, + enabled: false, + required: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + }, + )]); + + apply_blocking( + codex_home.path(), + None, + &[ConfigEdit::ReplaceMcpServers(servers.clone())], + )?; + + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + let serialized = std::fs::read_to_string(&config_path)?; + assert!( + serialized.contains("enabled = false"), + "serialized config missing disabled flag:\n{serialized}" + ); + + let loaded = load_global_mcp_servers(codex_home.path()).await?; + let docs = loaded.get("docs").expect("docs entry"); + assert!(!docs.enabled); + + Ok(()) +} + +#[tokio::test] +async fn replace_mcp_servers_serializes_required_flag() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + let servers = BTreeMap::from([( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "docs-server".to_string(), + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: None, + }, + enabled: true, + required: true, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + }, + )]); + + apply_blocking( + codex_home.path(), + None, + &[ConfigEdit::ReplaceMcpServers(servers.clone())], + )?; + + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + let serialized = std::fs::read_to_string(&config_path)?; + assert!( + serialized.contains("required = true"), + "serialized config missing required flag:\n{serialized}" + ); + + let loaded = load_global_mcp_servers(codex_home.path()).await?; + let docs = loaded.get("docs").expect("docs entry"); + assert!(docs.required); + + Ok(()) +} + +#[tokio::test] +async fn replace_mcp_servers_serializes_tool_filters() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + let servers = BTreeMap::from([( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "docs-server".to_string(), + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: None, + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: Some(vec!["allowed".to_string()]), + disabled_tools: Some(vec!["blocked".to_string()]), + scopes: None, + oauth_resource: None, + }, + )]); + + apply_blocking( + codex_home.path(), + None, + &[ConfigEdit::ReplaceMcpServers(servers.clone())], + )?; + + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + let serialized = std::fs::read_to_string(&config_path)?; + assert!(serialized.contains(r#"enabled_tools = ["allowed"]"#)); + assert!(serialized.contains(r#"disabled_tools = ["blocked"]"#)); + + let loaded = load_global_mcp_servers(codex_home.path()).await?; + let docs = loaded.get("docs").expect("docs entry"); + assert_eq!( + docs.enabled_tools.as_ref(), + Some(&vec!["allowed".to_string()]) + ); + assert_eq!( + docs.disabled_tools.as_ref(), + Some(&vec!["blocked".to_string()]) + ); + + Ok(()) +} + +#[tokio::test] +async fn replace_mcp_servers_streamable_http_serializes_oauth_resource() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + let servers = BTreeMap::from([( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: Some("https://resource.example.com".to_string()), + }, + )]); + + apply_blocking( + codex_home.path(), + None, + &[ConfigEdit::ReplaceMcpServers(servers.clone())], + )?; + + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + let serialized = std::fs::read_to_string(&config_path)?; + assert!(serialized.contains(r#"oauth_resource = "https://resource.example.com""#)); + + let loaded = load_global_mcp_servers(codex_home.path()).await?; + let docs = loaded.get("docs").expect("docs entry"); + assert_eq!( + docs.oauth_resource.as_deref(), + Some("https://resource.example.com") + ); + + Ok(()) +} + +#[tokio::test] +async fn set_model_updates_defaults() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + ConfigEditsBuilder::new(codex_home.path()) + .set_model(Some("gpt-5.1-codex"), Some(ReasoningEffort::High)) + .apply() + .await?; + + let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; + let parsed: ConfigToml = toml::from_str(&serialized)?; + + assert_eq!(parsed.model.as_deref(), Some("gpt-5.1-codex")); + assert_eq!(parsed.model_reasoning_effort, Some(ReasoningEffort::High)); + + Ok(()) +} + +#[tokio::test] +async fn set_model_overwrites_existing_model() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + + tokio::fs::write( + &config_path, + r#" +model = "gpt-5.1-codex" +model_reasoning_effort = "medium" + +[profiles.dev] +model = "gpt-4.1" +"#, + ) + .await?; + + ConfigEditsBuilder::new(codex_home.path()) + .set_model(Some("o4-mini"), Some(ReasoningEffort::High)) + .apply() + .await?; + + let serialized = tokio::fs::read_to_string(config_path).await?; + let parsed: ConfigToml = toml::from_str(&serialized)?; + + assert_eq!(parsed.model.as_deref(), Some("o4-mini")); + assert_eq!(parsed.model_reasoning_effort, Some(ReasoningEffort::High)); + assert_eq!( + parsed + .profiles + .get("dev") + .and_then(|profile| profile.model.as_deref()), + Some("gpt-4.1"), + ); + + Ok(()) +} + +#[tokio::test] +async fn set_model_updates_profile() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + ConfigEditsBuilder::new(codex_home.path()) + .with_profile(Some("dev")) + .set_model(Some("gpt-5.1-codex"), Some(ReasoningEffort::Medium)) + .apply() + .await?; + + let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; + let parsed: ConfigToml = toml::from_str(&serialized)?; + let profile = parsed + .profiles + .get("dev") + .expect("profile should be created"); + + assert_eq!(profile.model.as_deref(), Some("gpt-5.1-codex")); + assert_eq!( + profile.model_reasoning_effort, + Some(ReasoningEffort::Medium) + ); + + Ok(()) +} + +#[tokio::test] +async fn set_model_updates_existing_profile() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + + tokio::fs::write( + &config_path, + r#" +[profiles.dev] +model = "gpt-4" +model_reasoning_effort = "medium" + +[profiles.prod] +model = "gpt-5.1-codex" +"#, + ) + .await?; + + ConfigEditsBuilder::new(codex_home.path()) + .with_profile(Some("dev")) + .set_model(Some("o4-high"), Some(ReasoningEffort::Medium)) + .apply() + .await?; + + let serialized = tokio::fs::read_to_string(config_path).await?; + let parsed: ConfigToml = toml::from_str(&serialized)?; + + let dev_profile = parsed + .profiles + .get("dev") + .expect("dev profile should survive updates"); + assert_eq!(dev_profile.model.as_deref(), Some("o4-high")); + assert_eq!( + dev_profile.model_reasoning_effort, + Some(ReasoningEffort::Medium) + ); + + assert_eq!( + parsed + .profiles + .get("prod") + .and_then(|profile| profile.model.as_deref()), + Some("gpt-5.1-codex"), + ); + + Ok(()) +} + +struct PrecedenceTestFixture { + cwd: TempDir, + codex_home: TempDir, + cfg: ConfigToml, + model_provider_map: HashMap, + openai_provider: ModelProviderInfo, + openai_custom_provider: ModelProviderInfo, +} + +impl PrecedenceTestFixture { + fn cwd(&self) -> PathBuf { + self.cwd.path().to_path_buf() + } + + fn codex_home(&self) -> PathBuf { + self.codex_home.path().to_path_buf() + } +} + +#[test] +fn cli_override_sets_compact_prompt() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let overrides = ConfigOverrides { + compact_prompt: Some("Use the compact override".to_string()), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + overrides, + codex_home.path().to_path_buf(), + )?; + + assert_eq!( + config.compact_prompt.as_deref(), + Some("Use the compact override") + ); + + Ok(()) +} + +#[test] +fn loads_compact_prompt_from_file() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let workspace = codex_home.path().join("workspace"); + std::fs::create_dir_all(&workspace)?; + + let prompt_path = workspace.join("compact_prompt.txt"); + std::fs::write(&prompt_path, " summarize differently ")?; + + let cfg = ConfigToml { + experimental_compact_prompt_file: Some(AbsolutePathBuf::from_absolute_path(prompt_path)?), + ..Default::default() + }; + + let overrides = ConfigOverrides { + cwd: Some(workspace), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + cfg, + overrides, + codex_home.path().to_path_buf(), + )?; + + assert_eq!( + config.compact_prompt.as_deref(), + Some("summarize differently") + ); + + Ok(()) +} + +#[test] +fn load_config_rejects_missing_agent_role_config_file() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let missing_path = codex_home.path().join("agents").join("researcher.toml"); + let cfg = ConfigToml { + agents: Some(AgentsToml { + max_threads: None, + max_depth: None, + job_max_runtime_seconds: None, + roles: BTreeMap::from([( + "researcher".to_string(), + AgentRoleToml { + description: Some("Research role".to_string()), + config_file: Some(AbsolutePathBuf::from_absolute_path(missing_path)?), + nickname_candidates: None, + }, + )]), + }), + ..Default::default() + }; + + let result = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + ); + let err = result.expect_err("missing role config file should be rejected"); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + let message = err.to_string(); + assert!(message.contains("agents.researcher.config_file")); + assert!(message.contains("must point to an existing file")); + + Ok(()) +} + +#[tokio::test] +async fn agent_role_relative_config_file_resolves_against_config_toml() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let role_config_path = codex_home.path().join("agents").join("researcher.toml"); + tokio::fs::create_dir_all( + role_config_path + .parent() + .expect("role config should have a parent directory"), + ) + .await?; + tokio::fs::write(&role_config_path, "model = \"gpt-5\"").await?; + tokio::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"[agents.researcher] +description = "Research role" +config_file = "./agents/researcher.toml" +nickname_candidates = ["Hypatia", "Noether"] +"#, + ) + .await?; + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .build() + .await?; + assert_eq!( + config + .agent_roles + .get("researcher") + .and_then(|role| role.config_file.as_ref()), + Some(&role_config_path) + ); + assert_eq!( + config + .agent_roles + .get("researcher") + .and_then(|role| role.nickname_candidates.as_ref()) + .map(|candidates| candidates.iter().map(String::as_str).collect::>()), + Some(vec!["Hypatia", "Noether"]) + ); + + Ok(()) +} + +#[test] +fn load_config_normalizes_agent_role_nickname_candidates() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cfg = ConfigToml { + agents: Some(AgentsToml { + max_threads: None, + max_depth: None, + job_max_runtime_seconds: None, + roles: BTreeMap::from([( + "researcher".to_string(), + AgentRoleToml { + description: Some("Research role".to_string()), + config_file: None, + nickname_candidates: Some(vec![ + " Hypatia ".to_string(), + "Noether".to_string(), + ]), + }, + )]), + }), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert_eq!( + config + .agent_roles + .get("researcher") + .and_then(|role| role.nickname_candidates.as_ref()) + .map(|candidates| candidates.iter().map(String::as_str).collect::>()), + Some(vec!["Hypatia", "Noether"]) + ); + + Ok(()) +} + +#[test] +fn load_config_rejects_empty_agent_role_nickname_candidates() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cfg = ConfigToml { + agents: Some(AgentsToml { + max_threads: None, + max_depth: None, + job_max_runtime_seconds: None, + roles: BTreeMap::from([( + "researcher".to_string(), + AgentRoleToml { + description: Some("Research role".to_string()), + config_file: None, + nickname_candidates: Some(Vec::new()), + }, + )]), + }), + ..Default::default() + }; + + let result = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + ); + let err = result.expect_err("empty nickname candidates should be rejected"); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert!( + err.to_string() + .contains("agents.researcher.nickname_candidates") + ); + + Ok(()) +} + +#[test] +fn load_config_rejects_duplicate_agent_role_nickname_candidates() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cfg = ConfigToml { + agents: Some(AgentsToml { + max_threads: None, + max_depth: None, + job_max_runtime_seconds: None, + roles: BTreeMap::from([( + "researcher".to_string(), + AgentRoleToml { + description: Some("Research role".to_string()), + config_file: None, + nickname_candidates: Some(vec!["Hypatia".to_string(), " Hypatia ".to_string()]), + }, + )]), + }), + ..Default::default() + }; + + let result = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + ); + let err = result.expect_err("duplicate nickname candidates should be rejected"); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert!( + err.to_string() + .contains("agents.researcher.nickname_candidates cannot contain duplicates") + ); + + Ok(()) +} + +#[test] +fn load_config_rejects_unsafe_agent_role_nickname_candidates() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cfg = ConfigToml { + agents: Some(AgentsToml { + max_threads: None, + max_depth: None, + job_max_runtime_seconds: None, + roles: BTreeMap::from([( + "researcher".to_string(), + AgentRoleToml { + description: Some("Research role".to_string()), + config_file: None, + nickname_candidates: Some(vec!["Agent ".to_string()]), + }, + )]), + }), + ..Default::default() + }; + + let result = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + ); + let err = result.expect_err("unsafe nickname candidates should be rejected"); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert!(err.to_string().contains( + "agents.researcher.nickname_candidates may only contain ASCII letters, digits, spaces, hyphens, and underscores" + )); + + Ok(()) +} + +#[test] +fn model_catalog_json_loads_from_path() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let catalog_path = codex_home.path().join("catalog.json"); + let mut catalog: ModelsResponse = + serde_json::from_str(include_str!("../../models.json")).expect("valid models.json"); + catalog.models = catalog.models.into_iter().take(1).collect(); + std::fs::write( + &catalog_path, + serde_json::to_string(&catalog).expect("serialize catalog"), + )?; + + let cfg = ConfigToml { + model_catalog_json: Some(AbsolutePathBuf::from_absolute_path(catalog_path)?), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert_eq!(config.model_catalog, Some(catalog)); + Ok(()) +} + +#[test] +fn model_catalog_json_rejects_empty_catalog() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let catalog_path = codex_home.path().join("catalog.json"); + std::fs::write(&catalog_path, r#"{"models":[]}"#)?; + + let cfg = ConfigToml { + model_catalog_json: Some(AbsolutePathBuf::from_absolute_path(catalog_path)?), + ..Default::default() + }; + + let err = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + ) + .expect_err("empty custom catalog should fail config load"); + + assert_eq!(err.kind(), ErrorKind::InvalidData); + assert!( + err.to_string().contains("must contain at least one model"), + "unexpected error: {err}" + ); + Ok(()) +} + +fn create_test_fixture() -> std::io::Result { + let toml = r#" +model = "o3" +approval_policy = "untrusted" + +# Can be used to determine which profile to use if not specified by +# `ConfigOverrides`. +profile = "gpt3" + +[analytics] +enabled = true + +[model_providers.openai-custom] +name = "OpenAI custom" +base_url = "https://api.openai.com/v1" +env_key = "OPENAI_API_KEY" +wire_api = "responses" +request_max_retries = 4 # retry failed HTTP requests +stream_max_retries = 10 # retry dropped SSE streams +stream_idle_timeout_ms = 300000 # 5m idle timeout + +[profiles.o3] +model = "o3" +model_provider = "openai" +approval_policy = "never" +model_reasoning_effort = "high" +model_reasoning_summary = "detailed" + +[profiles.gpt3] +model = "gpt-3.5-turbo" +model_provider = "openai-custom" + +[profiles.zdr] +model = "o3" +model_provider = "openai" +approval_policy = "on-failure" + +[profiles.zdr.analytics] +enabled = false + +[profiles.gpt5] +model = "gpt-5.1" +model_provider = "openai" +approval_policy = "on-failure" +model_reasoning_effort = "high" +model_reasoning_summary = "detailed" +model_verbosity = "high" +"#; + + let cfg: ConfigToml = toml::from_str(toml).expect("TOML deserialization should succeed"); + + // Use a temporary directory for the cwd so it does not contain an + // AGENTS.md file. + let cwd_temp_dir = TempDir::new().unwrap(); + let cwd = cwd_temp_dir.path().to_path_buf(); + // Make it look like a Git repo so it does not search for AGENTS.md in + // a parent folder, either. + std::fs::write(cwd.join(".git"), "gitdir: nowhere")?; + + let codex_home_temp_dir = TempDir::new().unwrap(); + + let openai_custom_provider = ModelProviderInfo { + name: "OpenAI custom".to_string(), + base_url: Some("https://api.openai.com/v1".to_string()), + env_key: Some("OPENAI_API_KEY".to_string()), + wire_api: crate::WireApi::Responses, + env_key_instructions: None, + experimental_bearer_token: None, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: Some(4), + stream_max_retries: Some(10), + stream_idle_timeout_ms: Some(300_000), + requires_openai_auth: false, + supports_websockets: false, + }; + let model_provider_map = { + let mut model_provider_map = built_in_model_providers(); + model_provider_map.insert("openai-custom".to_string(), openai_custom_provider.clone()); + model_provider_map + }; + + let openai_provider = model_provider_map + .get("openai") + .expect("openai provider should exist") + .clone(); + + Ok(PrecedenceTestFixture { + cwd: cwd_temp_dir, + codex_home: codex_home_temp_dir, + cfg, + model_provider_map, + openai_provider, + openai_custom_provider, + }) +} + +/// Users can specify config values at multiple levels that have the +/// following precedence: +/// +/// 1. custom command-line argument, e.g. `--model o3` +/// 2. as part of a profile, where the `--profile` is specified via a CLI +/// (or in the config file itself) +/// 3. as an entry in `config.toml`, e.g. `model = "o3"` +/// 4. the default value for a required field defined in code, e.g., +/// `crate::flags::OPENAI_DEFAULT_MODEL` +/// +/// Note that profiles are the recommended way to specify a group of +/// configuration options together. +#[test] +fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { + let fixture = create_test_fixture()?; + + let o3_profile_overrides = ConfigOverrides { + config_profile: Some("o3".to_string()), + cwd: Some(fixture.cwd()), + ..Default::default() + }; + let o3_profile_config: Config = Config::load_from_base_config_with_overrides( + fixture.cfg.clone(), + o3_profile_overrides, + fixture.codex_home(), + )?; + assert_eq!( + Config { + model: Some("o3".to_string()), + review_model: None, + model_context_window: None, + model_auto_compact_token_limit: None, + service_tier: None, + model_provider_id: "openai".to_string(), + model_provider: fixture.openai_provider.clone(), + permissions: Permissions { + approval_policy: Constrained::allow_any(AskForApproval::Never), + sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), + network: None, + allow_login_shell: true, + shell_environment_policy: ShellEnvironmentPolicy::default(), + windows_sandbox_mode: None, + macos_seatbelt_profile_extensions: None, + }, + enforce_residency: Constrained::allow_any(None), + user_instructions: None, + notify: None, + cwd: fixture.cwd(), + cli_auth_credentials_store_mode: Default::default(), + mcp_servers: Constrained::allow_any(HashMap::new()), + mcp_oauth_credentials_store_mode: Default::default(), + mcp_oauth_callback_port: None, + mcp_oauth_callback_url: None, + model_providers: fixture.model_provider_map.clone(), + project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, + project_doc_fallback_filenames: Vec::new(), + tool_output_token_limit: None, + agent_max_threads: DEFAULT_AGENT_MAX_THREADS, + agent_max_depth: DEFAULT_AGENT_MAX_DEPTH, + agent_roles: BTreeMap::new(), + memories: MemoriesConfig::default(), + agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS, + codex_home: fixture.codex_home(), + sqlite_home: fixture.codex_home(), + log_dir: fixture.codex_home().join("log"), + config_layer_stack: Default::default(), + startup_warnings: Vec::new(), + history: History::default(), + ephemeral: false, + file_opener: UriBasedFileOpener::VsCode, + codex_linux_sandbox_exe: None, + main_execve_wrapper_exe: None, + js_repl_node_path: None, + js_repl_node_module_dirs: Vec::new(), + zsh_path: None, + hide_agent_reasoning: false, + show_raw_agent_reasoning: false, + model_reasoning_effort: Some(ReasoningEffort::High), + plan_mode_reasoning_effort: None, + model_reasoning_summary: Some(ReasoningSummary::Detailed), + model_supports_reasoning_summaries: None, + model_catalog: None, + model_verbosity: None, + personality: Some(Personality::Pragmatic), + chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), + realtime_audio: RealtimeAudioConfig::default(), + experimental_realtime_ws_base_url: None, + experimental_realtime_ws_model: None, + experimental_realtime_ws_backend_prompt: None, + base_instructions: None, + developer_instructions: None, + compact_prompt: None, + commit_attribution: None, + forced_chatgpt_workspace_id: None, + forced_login_method: None, + include_apply_patch_tool: false, + web_search_mode: Constrained::allow_any(WebSearchMode::Cached), + use_experimental_unified_exec_tool: !cfg!(windows), + background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS, + ghost_snapshot: GhostSnapshotConfig::default(), + features: Features::with_defaults().into(), + suppress_unstable_features_warning: false, + active_profile: Some("o3".to_string()), + active_project: ProjectConfig { trust_level: None }, + windows_wsl_setup_acknowledged: false, + notices: Default::default(), + check_for_update_on_startup: true, + disable_paste_burst: false, + tui_notifications: Default::default(), + tui_notification_method: Default::default(), + animations: true, + show_tooltips: true, + model_availability_nux: ModelAvailabilityNuxConfig::default(), + analytics_enabled: Some(true), + feedback_enabled: true, + tui_alternate_screen: AltScreenMode::Auto, + tui_status_line: None, + tui_theme: None, + otel: OtelConfig::default(), + }, + o3_profile_config + ); + Ok(()) +} + +#[test] +fn metrics_exporter_defaults_to_statsig_when_missing() -> std::io::Result<()> { + let fixture = create_test_fixture()?; + + let config = Config::load_from_base_config_with_overrides( + fixture.cfg.clone(), + ConfigOverrides { + cwd: Some(fixture.cwd()), + ..Default::default() + }, + fixture.codex_home(), + )?; + + assert_eq!(config.otel.metrics_exporter, OtelExporterKind::Statsig); + Ok(()) +} + +#[test] +fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { + let fixture = create_test_fixture()?; + + let gpt3_profile_overrides = ConfigOverrides { + config_profile: Some("gpt3".to_string()), + cwd: Some(fixture.cwd()), + ..Default::default() + }; + let gpt3_profile_config = Config::load_from_base_config_with_overrides( + fixture.cfg.clone(), + gpt3_profile_overrides, + fixture.codex_home(), + )?; + let expected_gpt3_profile_config = Config { + model: Some("gpt-3.5-turbo".to_string()), + review_model: None, + model_context_window: None, + model_auto_compact_token_limit: None, + service_tier: None, + model_provider_id: "openai-custom".to_string(), + model_provider: fixture.openai_custom_provider.clone(), + permissions: Permissions { + approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted), + sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), + network: None, + allow_login_shell: true, + shell_environment_policy: ShellEnvironmentPolicy::default(), + windows_sandbox_mode: None, + macos_seatbelt_profile_extensions: None, + }, + enforce_residency: Constrained::allow_any(None), + user_instructions: None, + notify: None, + cwd: fixture.cwd(), + cli_auth_credentials_store_mode: Default::default(), + mcp_servers: Constrained::allow_any(HashMap::new()), + mcp_oauth_credentials_store_mode: Default::default(), + mcp_oauth_callback_port: None, + mcp_oauth_callback_url: None, + model_providers: fixture.model_provider_map.clone(), + project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, + project_doc_fallback_filenames: Vec::new(), + tool_output_token_limit: None, + agent_max_threads: DEFAULT_AGENT_MAX_THREADS, + agent_max_depth: DEFAULT_AGENT_MAX_DEPTH, + agent_roles: BTreeMap::new(), + memories: MemoriesConfig::default(), + agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS, + codex_home: fixture.codex_home(), + sqlite_home: fixture.codex_home(), + log_dir: fixture.codex_home().join("log"), + config_layer_stack: Default::default(), + startup_warnings: Vec::new(), + history: History::default(), + ephemeral: false, + file_opener: UriBasedFileOpener::VsCode, + codex_linux_sandbox_exe: None, + main_execve_wrapper_exe: None, + js_repl_node_path: None, + js_repl_node_module_dirs: Vec::new(), + zsh_path: None, + hide_agent_reasoning: false, + show_raw_agent_reasoning: false, + model_reasoning_effort: None, + plan_mode_reasoning_effort: None, + model_reasoning_summary: None, + model_supports_reasoning_summaries: None, + model_catalog: None, + model_verbosity: None, + personality: Some(Personality::Pragmatic), + chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), + realtime_audio: RealtimeAudioConfig::default(), + experimental_realtime_ws_base_url: None, + experimental_realtime_ws_model: None, + experimental_realtime_ws_backend_prompt: None, + base_instructions: None, + developer_instructions: None, + compact_prompt: None, + commit_attribution: None, + forced_chatgpt_workspace_id: None, + forced_login_method: None, + include_apply_patch_tool: false, + web_search_mode: Constrained::allow_any(WebSearchMode::Cached), + use_experimental_unified_exec_tool: !cfg!(windows), + background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS, + ghost_snapshot: GhostSnapshotConfig::default(), + features: Features::with_defaults().into(), + suppress_unstable_features_warning: false, + active_profile: Some("gpt3".to_string()), + active_project: ProjectConfig { trust_level: None }, + windows_wsl_setup_acknowledged: false, + notices: Default::default(), + check_for_update_on_startup: true, + disable_paste_burst: false, + tui_notifications: Default::default(), + tui_notification_method: Default::default(), + animations: true, + show_tooltips: true, + model_availability_nux: ModelAvailabilityNuxConfig::default(), + analytics_enabled: Some(true), + feedback_enabled: true, + tui_alternate_screen: AltScreenMode::Auto, + tui_status_line: None, + tui_theme: None, + otel: OtelConfig::default(), + }; + + assert_eq!(expected_gpt3_profile_config, gpt3_profile_config); + + // Verify that loading without specifying a profile in ConfigOverrides + // uses the default profile from the config file (which is "gpt3"). + let default_profile_overrides = ConfigOverrides { + cwd: Some(fixture.cwd()), + ..Default::default() + }; + + let default_profile_config = Config::load_from_base_config_with_overrides( + fixture.cfg.clone(), + default_profile_overrides, + fixture.codex_home(), + )?; + + assert_eq!(expected_gpt3_profile_config, default_profile_config); + Ok(()) +} + +#[test] +fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { + let fixture = create_test_fixture()?; + + let zdr_profile_overrides = ConfigOverrides { + config_profile: Some("zdr".to_string()), + cwd: Some(fixture.cwd()), + ..Default::default() + }; + let zdr_profile_config = Config::load_from_base_config_with_overrides( + fixture.cfg.clone(), + zdr_profile_overrides, + fixture.codex_home(), + )?; + let expected_zdr_profile_config = Config { + model: Some("o3".to_string()), + review_model: None, + model_context_window: None, + model_auto_compact_token_limit: None, + service_tier: None, + model_provider_id: "openai".to_string(), + model_provider: fixture.openai_provider.clone(), + permissions: Permissions { + approval_policy: Constrained::allow_any(AskForApproval::OnFailure), + sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), + network: None, + allow_login_shell: true, + shell_environment_policy: ShellEnvironmentPolicy::default(), + windows_sandbox_mode: None, + macos_seatbelt_profile_extensions: None, + }, + enforce_residency: Constrained::allow_any(None), + user_instructions: None, + notify: None, + cwd: fixture.cwd(), + cli_auth_credentials_store_mode: Default::default(), + mcp_servers: Constrained::allow_any(HashMap::new()), + mcp_oauth_credentials_store_mode: Default::default(), + mcp_oauth_callback_port: None, + mcp_oauth_callback_url: None, + model_providers: fixture.model_provider_map.clone(), + project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, + project_doc_fallback_filenames: Vec::new(), + tool_output_token_limit: None, + agent_max_threads: DEFAULT_AGENT_MAX_THREADS, + agent_max_depth: DEFAULT_AGENT_MAX_DEPTH, + agent_roles: BTreeMap::new(), + memories: MemoriesConfig::default(), + agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS, + codex_home: fixture.codex_home(), + sqlite_home: fixture.codex_home(), + log_dir: fixture.codex_home().join("log"), + config_layer_stack: Default::default(), + startup_warnings: Vec::new(), + history: History::default(), + ephemeral: false, + file_opener: UriBasedFileOpener::VsCode, + codex_linux_sandbox_exe: None, + main_execve_wrapper_exe: None, + js_repl_node_path: None, + js_repl_node_module_dirs: Vec::new(), + zsh_path: None, + hide_agent_reasoning: false, + show_raw_agent_reasoning: false, + model_reasoning_effort: None, + plan_mode_reasoning_effort: None, + model_reasoning_summary: None, + model_supports_reasoning_summaries: None, + model_catalog: None, + model_verbosity: None, + personality: Some(Personality::Pragmatic), + chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), + realtime_audio: RealtimeAudioConfig::default(), + experimental_realtime_ws_base_url: None, + experimental_realtime_ws_model: None, + experimental_realtime_ws_backend_prompt: None, + base_instructions: None, + developer_instructions: None, + compact_prompt: None, + commit_attribution: None, + forced_chatgpt_workspace_id: None, + forced_login_method: None, + include_apply_patch_tool: false, + web_search_mode: Constrained::allow_any(WebSearchMode::Cached), + use_experimental_unified_exec_tool: !cfg!(windows), + background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS, + ghost_snapshot: GhostSnapshotConfig::default(), + features: Features::with_defaults().into(), + suppress_unstable_features_warning: false, + active_profile: Some("zdr".to_string()), + active_project: ProjectConfig { trust_level: None }, + windows_wsl_setup_acknowledged: false, + notices: Default::default(), + check_for_update_on_startup: true, + disable_paste_burst: false, + tui_notifications: Default::default(), + tui_notification_method: Default::default(), + animations: true, + show_tooltips: true, + model_availability_nux: ModelAvailabilityNuxConfig::default(), + analytics_enabled: Some(false), + feedback_enabled: true, + tui_alternate_screen: AltScreenMode::Auto, + tui_status_line: None, + tui_theme: None, + otel: OtelConfig::default(), + }; + + assert_eq!(expected_zdr_profile_config, zdr_profile_config); + + Ok(()) +} + +#[test] +fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { + let fixture = create_test_fixture()?; + + let gpt5_profile_overrides = ConfigOverrides { + config_profile: Some("gpt5".to_string()), + cwd: Some(fixture.cwd()), + ..Default::default() + }; + let gpt5_profile_config = Config::load_from_base_config_with_overrides( + fixture.cfg.clone(), + gpt5_profile_overrides, + fixture.codex_home(), + )?; + let expected_gpt5_profile_config = Config { + model: Some("gpt-5.1".to_string()), + review_model: None, + model_context_window: None, + model_auto_compact_token_limit: None, + service_tier: None, + model_provider_id: "openai".to_string(), + model_provider: fixture.openai_provider.clone(), + permissions: Permissions { + approval_policy: Constrained::allow_any(AskForApproval::OnFailure), + sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), + network: None, + allow_login_shell: true, + shell_environment_policy: ShellEnvironmentPolicy::default(), + windows_sandbox_mode: None, + macos_seatbelt_profile_extensions: None, + }, + enforce_residency: Constrained::allow_any(None), + user_instructions: None, + notify: None, + cwd: fixture.cwd(), + cli_auth_credentials_store_mode: Default::default(), + mcp_servers: Constrained::allow_any(HashMap::new()), + mcp_oauth_credentials_store_mode: Default::default(), + mcp_oauth_callback_port: None, + mcp_oauth_callback_url: None, + model_providers: fixture.model_provider_map.clone(), + project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, + project_doc_fallback_filenames: Vec::new(), + tool_output_token_limit: None, + agent_max_threads: DEFAULT_AGENT_MAX_THREADS, + agent_max_depth: DEFAULT_AGENT_MAX_DEPTH, + agent_roles: BTreeMap::new(), + memories: MemoriesConfig::default(), + agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS, + codex_home: fixture.codex_home(), + sqlite_home: fixture.codex_home(), + log_dir: fixture.codex_home().join("log"), + config_layer_stack: Default::default(), + startup_warnings: Vec::new(), + history: History::default(), + ephemeral: false, + file_opener: UriBasedFileOpener::VsCode, + codex_linux_sandbox_exe: None, + main_execve_wrapper_exe: None, + js_repl_node_path: None, + js_repl_node_module_dirs: Vec::new(), + zsh_path: None, + hide_agent_reasoning: false, + show_raw_agent_reasoning: false, + model_reasoning_effort: Some(ReasoningEffort::High), + plan_mode_reasoning_effort: None, + model_reasoning_summary: Some(ReasoningSummary::Detailed), + model_supports_reasoning_summaries: None, + model_catalog: None, + model_verbosity: Some(Verbosity::High), + personality: Some(Personality::Pragmatic), + chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), + realtime_audio: RealtimeAudioConfig::default(), + experimental_realtime_ws_base_url: None, + experimental_realtime_ws_model: None, + experimental_realtime_ws_backend_prompt: None, + base_instructions: None, + developer_instructions: None, + compact_prompt: None, + commit_attribution: None, + forced_chatgpt_workspace_id: None, + forced_login_method: None, + include_apply_patch_tool: false, + web_search_mode: Constrained::allow_any(WebSearchMode::Cached), + use_experimental_unified_exec_tool: !cfg!(windows), + background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS, + ghost_snapshot: GhostSnapshotConfig::default(), + features: Features::with_defaults().into(), + suppress_unstable_features_warning: false, + active_profile: Some("gpt5".to_string()), + active_project: ProjectConfig { trust_level: None }, + windows_wsl_setup_acknowledged: false, + notices: Default::default(), + check_for_update_on_startup: true, + disable_paste_burst: false, + tui_notifications: Default::default(), + tui_notification_method: Default::default(), + animations: true, + show_tooltips: true, + model_availability_nux: ModelAvailabilityNuxConfig::default(), + analytics_enabled: Some(true), + feedback_enabled: true, + tui_alternate_screen: AltScreenMode::Auto, + tui_status_line: None, + tui_theme: None, + otel: OtelConfig::default(), + }; + + assert_eq!(expected_gpt5_profile_config, gpt5_profile_config); + + Ok(()) +} + +#[test] +fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() -> anyhow::Result<()> { + let fixture = create_test_fixture()?; + + let requirements_toml = crate::config_loader::ConfigRequirementsToml { + allowed_approval_policies: None, + allowed_sandbox_modes: None, + allowed_web_search_modes: Some(vec![ + crate::config_loader::WebSearchModeRequirement::Cached, + ]), + feature_requirements: None, + mcp_servers: None, + rules: None, + enforce_residency: None, + network: None, + }; + let requirement_source = crate::config_loader::RequirementSource::Unknown; + let requirement_source_for_error = requirement_source.clone(); + let allowed = vec![WebSearchMode::Disabled, WebSearchMode::Cached]; + let constrained = Constrained::new(WebSearchMode::Cached, move |candidate| { + if matches!(candidate, WebSearchMode::Cached | WebSearchMode::Disabled) { + Ok(()) + } else { + Err(ConstraintError::InvalidValue { + field_name: "web_search_mode", + candidate: format!("{candidate:?}"), + allowed: format!("{allowed:?}"), + requirement_source: requirement_source_for_error.clone(), + }) + } + })?; + let requirements = crate::config_loader::ConfigRequirements { + web_search_mode: crate::config_loader::ConstrainedWithSource::new( + constrained, + Some(requirement_source), + ), + ..Default::default() + }; + let config_layer_stack = + crate::config_loader::ConfigLayerStack::new(Vec::new(), requirements, requirements_toml) + .expect("config layer stack"); + + let config = Config::load_config_with_layer_stack( + fixture.cfg.clone(), + ConfigOverrides { + cwd: Some(fixture.cwd()), + ..Default::default() + }, + fixture.codex_home(), + config_layer_stack, + )?; + + assert!( + !config + .startup_warnings + .iter() + .any(|warning| warning.contains("Configured value for `web_search_mode`")), + "{:?}", + config.startup_warnings + ); + + Ok(()) +} + +#[test] +fn test_set_project_trusted_writes_explicit_tables() -> anyhow::Result<()> { + let project_dir = Path::new("/some/path"); + let mut doc = DocumentMut::new(); + + set_project_trust_level_inner(&mut doc, project_dir, TrustLevel::Trusted)?; + + let contents = doc.to_string(); + + let raw_path = project_dir.to_string_lossy(); + let path_str = if raw_path.contains('\\') { + format!("'{raw_path}'") + } else { + format!("\"{raw_path}\"") + }; + let expected = format!( + r#"[projects.{path_str}] +trust_level = "trusted" +"# + ); + assert_eq!(contents, expected); + + Ok(()) +} + +#[test] +fn test_set_project_trusted_converts_inline_to_explicit() -> anyhow::Result<()> { + let project_dir = Path::new("/some/path"); + + // Seed config.toml with an inline project entry under [projects] + let raw_path = project_dir.to_string_lossy(); + let path_str = if raw_path.contains('\\') { + format!("'{raw_path}'") + } else { + format!("\"{raw_path}\"") + }; + // Use a quoted key so backslashes don't require escaping on Windows + let initial = format!( + r#"[projects] +{path_str} = {{ trust_level = "untrusted" }} +"# + ); + let mut doc = initial.parse::()?; + + // Run the function; it should convert to explicit tables and set trusted + set_project_trust_level_inner(&mut doc, project_dir, TrustLevel::Trusted)?; + + let contents = doc.to_string(); + + // Assert exact output after conversion to explicit table + let expected = format!( + r#"[projects] + +[projects.{path_str}] +trust_level = "trusted" +"# + ); + assert_eq!(contents, expected); + + Ok(()) +} + +#[test] +fn test_set_project_trusted_migrates_top_level_inline_projects_preserving_entries() +-> anyhow::Result<()> { + let initial = r#"toplevel = "baz" +projects = { "/Users/mbolin/code/codex4" = { trust_level = "trusted", foo = "bar" } , "/Users/mbolin/code/codex3" = { trust_level = "trusted" } } +model = "foo""#; + let mut doc = initial.parse::()?; + + // Approve a new directory + let new_project = Path::new("/Users/mbolin/code/codex2"); + set_project_trust_level_inner(&mut doc, new_project, TrustLevel::Trusted)?; + + let contents = doc.to_string(); + + // Since we created the [projects] table as part of migration, it is kept implicit. + // Expect explicit per-project tables, preserving prior entries and appending the new one. + let expected = r#"toplevel = "baz" +model = "foo" + +[projects."/Users/mbolin/code/codex4"] +trust_level = "trusted" +foo = "bar" + +[projects."/Users/mbolin/code/codex3"] +trust_level = "trusted" + +[projects."/Users/mbolin/code/codex2"] +trust_level = "trusted" +"#; + assert_eq!(contents, expected); + + Ok(()) +} + +#[test] +fn test_set_default_oss_provider() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let codex_home = temp_dir.path(); + let config_path = codex_home.join(CONFIG_TOML_FILE); + + // Test setting valid provider on empty config + set_default_oss_provider(codex_home, OLLAMA_OSS_PROVIDER_ID)?; + let content = std::fs::read_to_string(&config_path)?; + assert!(content.contains("oss_provider = \"ollama\"")); + + // Test updating existing config + std::fs::write(&config_path, "model = \"gpt-4\"\n")?; + set_default_oss_provider(codex_home, LMSTUDIO_OSS_PROVIDER_ID)?; + let content = std::fs::read_to_string(&config_path)?; + assert!(content.contains("oss_provider = \"lmstudio\"")); + assert!(content.contains("model = \"gpt-4\"")); + + // Test overwriting existing oss_provider + set_default_oss_provider(codex_home, OLLAMA_OSS_PROVIDER_ID)?; + let content = std::fs::read_to_string(&config_path)?; + assert!(content.contains("oss_provider = \"ollama\"")); + assert!(!content.contains("oss_provider = \"lmstudio\"")); + + // Test invalid provider + let result = set_default_oss_provider(codex_home, "invalid_provider"); + assert!(result.is_err()); + let error = result.unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + assert!(error.to_string().contains("Invalid OSS provider")); + assert!(error.to_string().contains("invalid_provider")); + + Ok(()) +} + +#[test] +fn test_set_default_oss_provider_rejects_legacy_ollama_chat_provider() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let codex_home = temp_dir.path(); + + let result = set_default_oss_provider(codex_home, LEGACY_OLLAMA_CHAT_PROVIDER_ID); + assert!(result.is_err()); + let error = result.unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + assert!( + error + .to_string() + .contains(OLLAMA_CHAT_PROVIDER_REMOVED_ERROR) + ); + + Ok(()) +} + +#[test] +fn test_load_config_rejects_legacy_ollama_chat_provider_with_helpful_error() -> std::io::Result<()> +{ + let codex_home = TempDir::new()?; + let cfg = ConfigToml { + model_provider: Some(LEGACY_OLLAMA_CHAT_PROVIDER_ID.to_string()), + ..Default::default() + }; + + let result = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + ); + assert!(result.is_err()); + let error = result.unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::NotFound); + assert!( + error + .to_string() + .contains(OLLAMA_CHAT_PROVIDER_REMOVED_ERROR) + ); + + Ok(()) +} + +#[test] +fn test_untrusted_project_gets_workspace_write_sandbox() -> anyhow::Result<()> { + let config_with_untrusted = r#" +[projects."/tmp/test"] +trust_level = "untrusted" +"#; + + let cfg = toml::from_str::(config_with_untrusted) + .expect("TOML deserialization should succeed"); + + let resolution = cfg.derive_sandbox_policy( + None, + None, + WindowsSandboxLevel::Disabled, + &PathBuf::from("/tmp/test"), + None, + ); + + // Verify that untrusted projects get WorkspaceWrite (or ReadOnly on Windows due to downgrade) + if cfg!(target_os = "windows") { + assert!( + matches!(resolution, SandboxPolicy::ReadOnly { .. }), + "Expected ReadOnly on Windows, got {resolution:?}" + ); + } else { + assert!( + matches!(resolution, SandboxPolicy::WorkspaceWrite { .. }), + "Expected WorkspaceWrite for untrusted project, got {resolution:?}" + ); + } + + Ok(()) +} + +#[test] +fn derive_sandbox_policy_falls_back_to_constraint_value_for_implicit_defaults() -> anyhow::Result<()> +{ + let project_dir = TempDir::new()?; + let project_path = project_dir.path().to_path_buf(); + let project_key = project_path.to_string_lossy().to_string(); + let cfg = ConfigToml { + projects: Some(HashMap::from([( + project_key, + ProjectConfig { + trust_level: Some(TrustLevel::Trusted), + }, + )])), + ..Default::default() + }; + let constrained = Constrained::new(SandboxPolicy::DangerFullAccess, |candidate| { + if matches!(candidate, SandboxPolicy::DangerFullAccess) { + Ok(()) + } else { + Err(ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: format!("{candidate:?}"), + allowed: "[DangerFullAccess]".to_string(), + requirement_source: RequirementSource::Unknown, + }) + } + })?; + + let resolution = cfg.derive_sandbox_policy( + None, + None, + WindowsSandboxLevel::Disabled, + &project_path, + Some(&constrained), + ); + + assert_eq!(resolution, SandboxPolicy::DangerFullAccess); + Ok(()) +} + +#[test] +fn derive_sandbox_policy_preserves_windows_downgrade_for_unsupported_fallback() -> anyhow::Result<()> +{ + let project_dir = TempDir::new()?; + let project_path = project_dir.path().to_path_buf(); + let project_key = project_path.to_string_lossy().to_string(); + let cfg = ConfigToml { + projects: Some(HashMap::from([( + project_key, + ProjectConfig { + trust_level: Some(TrustLevel::Trusted), + }, + )])), + ..Default::default() + }; + let constrained = Constrained::new(SandboxPolicy::new_workspace_write_policy(), |candidate| { + if matches!(candidate, SandboxPolicy::WorkspaceWrite { .. }) { + Ok(()) + } else { + Err(ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: format!("{candidate:?}"), + allowed: "[WorkspaceWrite]".to_string(), + requirement_source: RequirementSource::Unknown, + }) + } + })?; + + let resolution = cfg.derive_sandbox_policy( + None, + None, + WindowsSandboxLevel::Disabled, + &project_path, + Some(&constrained), + ); + + if cfg!(target_os = "windows") { + assert_eq!(resolution, SandboxPolicy::new_read_only_policy()); + } else { + assert_eq!(resolution, SandboxPolicy::new_workspace_write_policy()); + } + Ok(()) +} + +#[test] +fn test_resolve_oss_provider_explicit_override() { + let config_toml = ConfigToml::default(); + let result = resolve_oss_provider(Some("custom-provider"), &config_toml, None); + assert_eq!(result, Some("custom-provider".to_string())); +} + +#[test] +fn test_resolve_oss_provider_from_profile() { + let mut profiles = std::collections::HashMap::new(); + let profile = ConfigProfile { + oss_provider: Some("profile-provider".to_string()), + ..Default::default() + }; + profiles.insert("test-profile".to_string(), profile); + let config_toml = ConfigToml { + profiles, + ..Default::default() + }; + + let result = resolve_oss_provider(None, &config_toml, Some("test-profile".to_string())); + assert_eq!(result, Some("profile-provider".to_string())); +} + +#[test] +fn test_resolve_oss_provider_from_global_config() { + let config_toml = ConfigToml { + oss_provider: Some("global-provider".to_string()), + ..Default::default() + }; + + let result = resolve_oss_provider(None, &config_toml, None); + assert_eq!(result, Some("global-provider".to_string())); +} + +#[test] +fn test_resolve_oss_provider_profile_fallback_to_global() { + let mut profiles = std::collections::HashMap::new(); + let profile = ConfigProfile::default(); // No oss_provider set + profiles.insert("test-profile".to_string(), profile); + let config_toml = ConfigToml { + oss_provider: Some("global-provider".to_string()), + profiles, + ..Default::default() + }; + + let result = resolve_oss_provider(None, &config_toml, Some("test-profile".to_string())); + assert_eq!(result, Some("global-provider".to_string())); +} + +#[test] +fn test_resolve_oss_provider_none_when_not_configured() { + let config_toml = ConfigToml::default(); + let result = resolve_oss_provider(None, &config_toml, None); + assert_eq!(result, None); +} + +#[test] +fn test_resolve_oss_provider_explicit_overrides_all() { + let mut profiles = std::collections::HashMap::new(); + let profile = ConfigProfile { + oss_provider: Some("profile-provider".to_string()), + ..Default::default() + }; + profiles.insert("test-profile".to_string(), profile); + let config_toml = ConfigToml { + oss_provider: Some("global-provider".to_string()), + profiles, + ..Default::default() + }; + + let result = resolve_oss_provider( + Some("explicit-provider"), + &config_toml, + Some("test-profile".to_string()), + ); + assert_eq!(result, Some("explicit-provider".to_string())); +} + +#[test] +fn config_toml_deserializes_mcp_oauth_callback_port() { + let toml = r#"mcp_oauth_callback_port = 4321"#; + let cfg: ConfigToml = + toml::from_str(toml).expect("TOML deserialization should succeed for callback port"); + assert_eq!(cfg.mcp_oauth_callback_port, Some(4321)); +} + +#[test] +fn config_toml_deserializes_mcp_oauth_callback_url() { + let toml = r#"mcp_oauth_callback_url = "https://example.com/callback""#; + let cfg: ConfigToml = + toml::from_str(toml).expect("TOML deserialization should succeed for callback URL"); + assert_eq!( + cfg.mcp_oauth_callback_url.as_deref(), + Some("https://example.com/callback") + ); +} + +#[test] +fn config_loads_mcp_oauth_callback_port_from_toml() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let toml = r#" +model = "gpt-5.1" +mcp_oauth_callback_port = 5678 +"#; + let cfg: ConfigToml = + toml::from_str(toml).expect("TOML deserialization should succeed for callback port"); + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert_eq!(config.mcp_oauth_callback_port, Some(5678)); + Ok(()) +} + +#[test] +fn config_loads_allow_login_shell_from_toml() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cfg: ConfigToml = toml::from_str( + r#" +model = "gpt-5.1" +allow_login_shell = false +"#, + ) + .expect("TOML deserialization should succeed for allow_login_shell"); + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert!(!config.permissions.allow_login_shell); + Ok(()) +} + +#[test] +fn config_loads_mcp_oauth_callback_url_from_toml() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let toml = r#" +model = "gpt-5.1" +mcp_oauth_callback_url = "https://example.com/callback" +"#; + let cfg: ConfigToml = + toml::from_str(toml).expect("TOML deserialization should succeed for callback URL"); + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert_eq!( + config.mcp_oauth_callback_url.as_deref(), + Some("https://example.com/callback") + ); + Ok(()) +} + +#[test] +fn test_untrusted_project_gets_unless_trusted_approval_policy() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let test_project_dir = TempDir::new()?; + let test_path = test_project_dir.path(); + + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + projects: Some(HashMap::from([( + test_path.to_string_lossy().to_string(), + ProjectConfig { + trust_level: Some(TrustLevel::Untrusted), + }, + )])), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(test_path.to_path_buf()), + ..Default::default() + }, + codex_home.path().to_path_buf(), + )?; + + // Verify that untrusted projects get UnlessTrusted approval policy + assert_eq!( + config.permissions.approval_policy.value(), + AskForApproval::UnlessTrusted, + "Expected UnlessTrusted approval policy for untrusted project" + ); + + // Verify that untrusted projects still get WorkspaceWrite sandbox (or ReadOnly on Windows) + if cfg!(target_os = "windows") { + assert!( + matches!( + config.permissions.sandbox_policy.get(), + SandboxPolicy::ReadOnly { .. } + ), + "Expected ReadOnly on Windows" + ); + } else { + assert!( + matches!( + config.permissions.sandbox_policy.get(), + SandboxPolicy::WorkspaceWrite { .. } + ), + "Expected WorkspaceWrite sandbox for untrusted project" + ); + } + + Ok(()) +} + +#[tokio::test] +async fn requirements_disallowing_default_sandbox_falls_back_to_required_default() +-> std::io::Result<()> { + let codex_home = TempDir::new()?; + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .cloud_requirements(CloudRequirementsLoader::new(async { + Ok(Some(crate::config_loader::ConfigRequirementsToml { + allowed_sandbox_modes: Some(vec![ + crate::config_loader::SandboxModeRequirement::ReadOnly, + ]), + ..Default::default() + })) + })) + .build() + .await?; + assert_eq!( + *config.permissions.sandbox_policy.get(), + SandboxPolicy::new_read_only_policy() + ); + Ok(()) +} + +#[tokio::test] +async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"sandbox_mode = "danger-full-access" +"#, + )?; + + let requirements = crate::config_loader::ConfigRequirementsToml { + allowed_approval_policies: None, + allowed_sandbox_modes: Some(vec![crate::config_loader::SandboxModeRequirement::ReadOnly]), + allowed_web_search_modes: None, + feature_requirements: None, + mcp_servers: None, + rules: None, + enforce_residency: None, + network: None, + }; + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .cloud_requirements(CloudRequirementsLoader::new(async move { + Ok(Some(requirements)) + })) + .build() + .await?; + assert_eq!( + *config.permissions.sandbox_policy.get(), + SandboxPolicy::new_read_only_policy() + ); + Ok(()) +} + +#[tokio::test] +async fn requirements_web_search_mode_overrides_danger_full_access_default() -> std::io::Result<()> +{ + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"sandbox_mode = "danger-full-access" +"#, + )?; + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .cloud_requirements(CloudRequirementsLoader::new(async { + Ok(Some(crate::config_loader::ConfigRequirementsToml { + allowed_web_search_modes: Some(vec![ + crate::config_loader::WebSearchModeRequirement::Cached, + ]), + ..Default::default() + })) + })) + .build() + .await?; + + assert_eq!(config.web_search_mode.value(), WebSearchMode::Cached); + assert_eq!( + resolve_web_search_mode_for_turn( + &config.web_search_mode, + config.permissions.sandbox_policy.get(), + ), + WebSearchMode::Cached, + ); + Ok(()) +} + +#[tokio::test] +async fn requirements_disallowing_default_approval_falls_back_to_required_default() +-> std::io::Result<()> { + let codex_home = TempDir::new()?; + let workspace = TempDir::new()?; + let workspace_key = workspace.path().to_string_lossy().replace('\\', "\\\\"); + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + format!( + r#" +[projects."{workspace_key}"] +trust_level = "untrusted" +"# + ), + )?; + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(workspace.path().to_path_buf())) + .cloud_requirements(CloudRequirementsLoader::new(async { + Ok(Some(crate::config_loader::ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), + ..Default::default() + })) + })) + .build() + .await?; + + assert_eq!( + config.permissions.approval_policy.value(), + AskForApproval::OnRequest + ); + Ok(()) +} + +#[tokio::test] +async fn explicit_approval_policy_falls_back_when_disallowed_by_requirements() -> std::io::Result<()> +{ + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"approval_policy = "untrusted" +"#, + )?; + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .cloud_requirements(CloudRequirementsLoader::new(async { + Ok(Some(crate::config_loader::ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), + ..Default::default() + })) + })) + .build() + .await?; + assert_eq!( + config.permissions.approval_policy.value(), + AskForApproval::OnRequest + ); + Ok(()) +} + +#[tokio::test] +async fn feature_requirements_normalize_effective_feature_values() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .cloud_requirements(CloudRequirementsLoader::new(async { + Ok(Some(crate::config_loader::ConfigRequirementsToml { + feature_requirements: Some(crate::config_loader::FeatureRequirementsToml { + entries: BTreeMap::from([ + ("personality".to_string(), true), + ("shell_tool".to_string(), false), + ]), + }), + ..Default::default() + })) + })) + .build() + .await?; + + assert!(config.features.enabled(Feature::Personality)); + assert!(!config.features.enabled(Feature::ShellTool)); + assert!( + !config + .startup_warnings + .iter() + .any(|warning| warning.contains("Configured value for `features`")), + "{:?}", + config.startup_warnings + ); + + Ok(()) +} + +#[tokio::test] +async fn explicit_feature_config_is_normalized_by_requirements() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#" +[features] +personality = false +shell_tool = true +"#, + )?; + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .cloud_requirements(CloudRequirementsLoader::new(async { + Ok(Some(crate::config_loader::ConfigRequirementsToml { + feature_requirements: Some(crate::config_loader::FeatureRequirementsToml { + entries: BTreeMap::from([ + ("personality".to_string(), true), + ("shell_tool".to_string(), false), + ]), + }), + ..Default::default() + })) + })) + .build() + .await?; + + assert!(config.features.enabled(Feature::Personality)); + assert!(!config.features.enabled(Feature::ShellTool)); + assert!( + !config + .startup_warnings + .iter() + .any(|warning| warning.contains("Configured value for `features`")), + "{:?}", + config.startup_warnings + ); + + Ok(()) +} + +#[tokio::test] +async fn feature_requirements_normalize_runtime_feature_mutations() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .cloud_requirements(CloudRequirementsLoader::new(async { + Ok(Some(crate::config_loader::ConfigRequirementsToml { + feature_requirements: Some(crate::config_loader::FeatureRequirementsToml { + entries: BTreeMap::from([ + ("personality".to_string(), true), + ("shell_tool".to_string(), false), + ]), + }), + ..Default::default() + })) + })) + .build() + .await?; + + let mut requested = config.features.get().clone(); + requested + .disable(Feature::Personality) + .enable(Feature::ShellTool); + assert!(config.features.can_set(&requested).is_ok()); + config + .features + .set(requested) + .expect("managed feature mutations should normalize successfully"); + + assert!(config.features.enabled(Feature::Personality)); + assert!(!config.features.enabled(Feature::ShellTool)); + + Ok(()) +} + +#[tokio::test] +async fn feature_requirements_reject_legacy_aliases() { + let codex_home = TempDir::new().expect("tempdir"); + + let err = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .cloud_requirements(CloudRequirementsLoader::new(async { + Ok(Some(crate::config_loader::ConfigRequirementsToml { + feature_requirements: Some(crate::config_loader::FeatureRequirementsToml { + entries: BTreeMap::from([("collab".to_string(), true)]), + }), + ..Default::default() + })) + })) + .build() + .await + .expect_err("legacy aliases should be rejected"); + + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); + assert!( + err.to_string() + .contains("use canonical feature key `multi_agent`"), + "{err}" + ); +} + +#[test] +fn experimental_realtime_ws_base_url_loads_from_config_toml() -> std::io::Result<()> { + let cfg: ConfigToml = toml::from_str( + r#" +experimental_realtime_ws_base_url = "http://127.0.0.1:8011" +"#, + ) + .expect("TOML deserialization should succeed"); + + assert_eq!( + cfg.experimental_realtime_ws_base_url.as_deref(), + Some("http://127.0.0.1:8011") + ); + + let codex_home = TempDir::new()?; + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert_eq!( + config.experimental_realtime_ws_base_url.as_deref(), + Some("http://127.0.0.1:8011") + ); + Ok(()) +} + +#[test] +fn experimental_realtime_ws_backend_prompt_loads_from_config_toml() -> std::io::Result<()> { + let cfg: ConfigToml = toml::from_str( + r#" +experimental_realtime_ws_backend_prompt = "prompt from config" +"#, + ) + .expect("TOML deserialization should succeed"); + + assert_eq!( + cfg.experimental_realtime_ws_backend_prompt.as_deref(), + Some("prompt from config") + ); + + let codex_home = TempDir::new()?; + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert_eq!( + config.experimental_realtime_ws_backend_prompt.as_deref(), + Some("prompt from config") + ); + Ok(()) +} + +#[test] +fn experimental_realtime_ws_model_loads_from_config_toml() -> std::io::Result<()> { + let cfg: ConfigToml = toml::from_str( + r#" +experimental_realtime_ws_model = "realtime-test-model" +"#, + ) + .expect("TOML deserialization should succeed"); + + assert_eq!( + cfg.experimental_realtime_ws_model.as_deref(), + Some("realtime-test-model") + ); + + let codex_home = TempDir::new()?; + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert_eq!( + config.experimental_realtime_ws_model.as_deref(), + Some("realtime-test-model") + ); + Ok(()) +} + +#[test] +fn realtime_audio_loads_from_config_toml() -> std::io::Result<()> { + let cfg: ConfigToml = toml::from_str( + r#" +[audio] +microphone = "USB Mic" +speaker = "Desk Speakers" +"#, + ) + .expect("TOML deserialization should succeed"); + + let realtime_audio = cfg + .audio + .as_ref() + .expect("realtime audio config should be present"); + assert_eq!(realtime_audio.microphone.as_deref(), Some("USB Mic")); + assert_eq!(realtime_audio.speaker.as_deref(), Some("Desk Speakers")); + + let codex_home = TempDir::new()?; + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert_eq!(config.realtime_audio.microphone.as_deref(), Some("USB Mic")); + assert_eq!( + config.realtime_audio.speaker.as_deref(), + Some("Desk Speakers") + ); + Ok(()) +} + +#[derive(Deserialize, Debug, PartialEq)] +struct TuiTomlTest { + #[serde(default)] + notifications: Notifications, + #[serde(default)] + notification_method: NotificationMethod, +} + +#[derive(Deserialize, Debug, PartialEq)] +struct RootTomlTest { + tui: TuiTomlTest, +} + +#[test] +fn test_tui_notifications_true() { + let toml = r#" + [tui] + notifications = true + "#; + let parsed: RootTomlTest = toml::from_str(toml).expect("deserialize notifications=true"); + assert_matches!(parsed.tui.notifications, Notifications::Enabled(true)); +} + +#[test] +fn test_tui_notifications_custom_array() { + let toml = r#" + [tui] + notifications = ["foo"] + "#; + let parsed: RootTomlTest = toml::from_str(toml).expect("deserialize notifications=[\"foo\"]"); + assert_matches!( + parsed.tui.notifications, + Notifications::Custom(ref v) if v == &vec!["foo".to_string()] + ); +} + +#[test] +fn test_tui_notification_method() { + let toml = r#" + [tui] + notification_method = "bel" + "#; + let parsed: RootTomlTest = + toml::from_str(toml).expect("deserialize notification_method=\"bel\""); + assert_eq!(parsed.tui.notification_method, NotificationMethod::Bel); +} diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 27d1d63f49..651d7f769e 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -85,8 +85,6 @@ use std::collections::HashMap; use std::io::ErrorKind; use std::path::Path; use std::path::PathBuf; -#[cfg(test)] -use tempfile::tempdir; use crate::config::permissions::network_proxy_config_from_permissions; use crate::config::profile::ConfigProfile; @@ -139,8 +137,10 @@ fn resolve_sqlite_home_env(resolved_cwd: &Path) -> Option { Some(resolved_cwd.join(path)) } } + #[cfg(test)] pub(crate) fn test_config() -> Config { + use tempfile::tempdir; let codex_home = tempdir().expect("create temp dir"); Config::load_from_base_config_with_overrides( ConfigToml::default(), @@ -2522,6 +2522,7 @@ mod tests { use std::collections::BTreeMap; use std::collections::HashMap; use std::time::Duration; + use tempfile::tempdir; use tempfile::TempDir; fn stdio_mcp(command: &str) -> McpServerConfig { diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 69a7cb933d..2b19acbdcc 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -538,7 +538,7 @@ pub fn render_decision_for_unmatched_command( // In restricted sandboxes (ReadOnly/WorkspaceWrite), do not prompt for // non‑escalated, non‑dangerous commands — let the sandbox enforce // restrictions (e.g., block network/write) without a user prompt. - if sandbox_permissions.requires_additional_permissions() { + if sandbox_permissions.requests_sandbox_override() { Decision::Prompt } else { Decision::Allow @@ -553,7 +553,7 @@ pub fn render_decision_for_unmatched_command( Decision::Allow } SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. } => { - if sandbox_permissions.requires_additional_permissions() { + if sandbox_permissions.requests_sandbox_override() { Decision::Prompt } else { Decision::Allow diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 9b822a85a1..d96654bf4c 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -111,6 +111,7 @@ pub mod terminal; mod tools; pub mod turn_diff_tracker; mod turn_metadata; +mod turn_timing; pub use rollout::ARCHIVED_SESSIONS_SUBDIR; pub use rollout::INTERACTIVE_SESSION_SOURCES; pub use rollout::RolloutRecorder; diff --git a/codex-rs/core/src/mentions.rs b/codex-rs/core/src/mentions.rs index 79efa3ec76..0c7a4fff05 100644 --- a/codex-rs/core/src/mentions.rs +++ b/codex-rs/core/src/mentions.rs @@ -10,6 +10,7 @@ use crate::skills::SkillMetadata; use crate::skills::injection::ToolMentionKind; use crate::skills::injection::app_id_from_path; use crate::skills::injection::extract_tool_mentions; +use crate::skills::injection::plugin_config_name_from_path; use crate::skills::injection::tool_kind_for_path; pub(crate) struct CollectedToolMentions { @@ -49,20 +50,7 @@ pub(crate) fn collect_explicit_app_ids(input: &[UserInput]) -> HashSet { .collect() } -/// Collect explicit plain-text `@plugin` mentions from user text. -/// -/// This is currently the core-side fallback path for plugin mentions. It -/// matches unambiguous plugin `display_name`s from the filtered capability -/// index, case-insensitively, by scanning for exact `@display name` matches. -/// -/// It is hand-rolled because core only has a `$...` / `[$...](...)` mention -/// parser today, and the existing TUI `@...` logic is file-autocomplete, not -/// turn-time parsing. -/// -/// Long term, explicit plugin picks should come through structured -/// `plugin://...` mentions, likely via `UserInput::Mention`, once clients can list -/// plugins and the UI has plugin-mention support (likely a plugins/list app-server -/// endpoint). Even then, this may stay as a text fallback, similar to skills/apps. +/// Collect explicit structured `plugin://...` mentions. pub(crate) fn collect_explicit_plugin_mentions( input: &[UserInput], plugins: &[PluginCapabilitySummary], @@ -71,79 +59,34 @@ pub(crate) fn collect_explicit_plugin_mentions( return Vec::new(); } - let mut display_name_counts = HashMap::new(); - for plugin in plugins { - *display_name_counts - .entry(plugin.display_name.to_lowercase()) - .or_insert(0) += 1; - } + let messages = input + .iter() + .filter_map(|item| match item { + UserInput::Text { text, .. } => Some(text.clone()), + _ => None, + }) + .collect::>(); - let mut display_names = display_name_counts.keys().cloned().collect::>(); - display_names.sort_by_key(|display_name| std::cmp::Reverse(display_name.len())); + let mentioned_config_names: HashSet = input + .iter() + .filter_map(|item| match item { + UserInput::Mention { path, .. } => Some(path.clone()), + _ => None, + }) + .chain(collect_tool_mentions_from_messages(&messages).paths) + .filter(|path| tool_kind_for_path(path.as_str()) == ToolMentionKind::Plugin) + .filter_map(|path| plugin_config_name_from_path(path.as_str()).map(str::to_string)) + .collect(); - let mut mentioned_display_names = HashSet::new(); - for text in input.iter().filter_map(|item| match item { - UserInput::Text { text, .. } => Some(text.as_str()), - _ => None, - }) { - let text = text.to_lowercase(); - let mut index = 0; - while let Some(relative_at_sign) = text[index..].find('@') { - let at_sign = index + relative_at_sign; - if text[..at_sign] - .chars() - .next_back() - .is_some_and(is_plugin_mention_body_char) - { - index = at_sign + 1; - continue; - } - - let Some((matched_display_name, matched_len)) = - display_names.iter().find_map(|display_name| { - text[at_sign + 1..].starts_with(display_name).then(|| { - let end = at_sign + 1 + display_name.len(); - text[end..] - .chars() - .next() - .is_none_or(|ch| !is_plugin_mention_body_char(ch)) - .then_some((display_name, display_name.len())) - })? - }) - else { - index = at_sign + 1; - continue; - }; - - if display_name_counts - .get(matched_display_name) - .copied() - .unwrap_or(0) - == 1 - { - mentioned_display_names.insert(matched_display_name.clone()); - } - index = at_sign + 1 + matched_len; - } - } - - if mentioned_display_names.is_empty() { + if mentioned_config_names.is_empty() { return Vec::new(); } - let mut selected = Vec::new(); - let mut seen_display_names = HashSet::new(); - for plugin in plugins { - let display_name = plugin.display_name.to_lowercase(); - if !mentioned_display_names.contains(&display_name) { - continue; - } - if seen_display_names.insert(display_name) { - selected.push(plugin.clone()); - } - } - - selected + plugins + .iter() + .filter(|plugin| mentioned_config_names.contains(plugin.config_name.as_str())) + .cloned() + .collect() } pub(crate) fn build_skill_name_counts( @@ -175,10 +118,6 @@ pub(crate) fn build_connector_slug_counts( counts } -fn is_plugin_mention_body_char(ch: char) -> bool { - ch.is_alphanumeric() || matches!(ch, '_' | '-' | ':') -} - #[cfg(test)] mod tests { use std::collections::HashSet; @@ -197,10 +136,11 @@ mod tests { } } - fn plugin(display_name: &str) -> PluginCapabilitySummary { + fn plugin(config_name: &str, display_name: &str) -> PluginCapabilitySummary { PluginCapabilitySummary { - config_name: format!("{display_name}@test"), + config_name: config_name.to_string(), display_name: display_name.to_string(), + description: None, has_skills: true, mcp_server_names: Vec::new(), app_connector_ids: Vec::new(), @@ -257,65 +197,67 @@ mod tests { } #[test] - fn collect_explicit_plugin_mentions_resolves_unique_display_names() { - let plugins = vec![plugin("sample"), plugin("other")]; - - let mentioned = collect_explicit_plugin_mentions(&[text_input("use @sample")], &plugins); - - assert_eq!(mentioned, vec![plugin("sample")]); - } - - #[test] - fn collect_explicit_plugin_mentions_resolves_non_slug_display_names() { - let spaced_plugins = vec![plugin("Google Calendar")]; - let spaced_mentioned = collect_explicit_plugin_mentions( - &[text_input("use @Google Calendar")], - &spaced_plugins, - ); - assert_eq!(spaced_mentioned, vec![plugin("Google Calendar")]); - - let unicode_plugins = vec![plugin("Café")]; - let unicode_mentioned = - collect_explicit_plugin_mentions(&[text_input("use @Café")], &unicode_plugins); - assert_eq!(unicode_mentioned, vec![plugin("Café")]); - } - - #[test] - fn collect_explicit_plugin_mentions_prefers_longer_display_names() { - let plugins = vec![plugin("Google"), plugin("Google Calendar")]; - - let mentioned = - collect_explicit_plugin_mentions(&[text_input("use @Google Calendar")], &plugins); - - assert_eq!(mentioned, vec![plugin("Google Calendar")]); - } - - #[test] - fn collect_explicit_plugin_mentions_does_not_fall_back_from_ambiguous_longer_name() { + fn collect_explicit_plugin_mentions_from_structured_paths() { let plugins = vec![ - plugin("Google"), - PluginCapabilitySummary { - config_name: "calendar-1@test".to_string(), - ..plugin("Google Calendar") - }, - PluginCapabilitySummary { - config_name: "calendar-2@test".to_string(), - ..plugin("Google Calendar") - }, + plugin("sample@test", "sample"), + plugin("other@test", "other"), ]; - let mentioned = - collect_explicit_plugin_mentions(&[text_input("use @Google Calendar")], &plugins); + let mentioned = collect_explicit_plugin_mentions( + &[UserInput::Mention { + name: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }], + &plugins, + ); - assert_eq!(mentioned, Vec::::new()); + assert_eq!(mentioned, vec![plugin("sample@test", "sample")]); } #[test] - fn collect_explicit_plugin_mentions_ignores_embedded_at_signs() { - let plugins = vec![plugin("sample")]; + fn collect_explicit_plugin_mentions_from_linked_text_mentions() { + let plugins = vec![ + plugin("sample@test", "sample"), + plugin("other@test", "other"), + ]; let mentioned = collect_explicit_plugin_mentions( - &[text_input("contact sample@openai.com, do not use plugins")], + &[text_input("use [$sample](plugin://sample@test)")], + &plugins, + ); + + assert_eq!(mentioned, vec![plugin("sample@test", "sample")]); + } + + #[test] + fn collect_explicit_plugin_mentions_dedupes_structured_and_linked_mentions() { + let plugins = vec![ + plugin("sample@test", "sample"), + plugin("other@test", "other"), + ]; + + let mentioned = collect_explicit_plugin_mentions( + &[ + text_input("use [$sample](plugin://sample@test)"), + UserInput::Mention { + name: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }, + ], + &plugins, + ); + + assert_eq!(mentioned, vec![plugin("sample@test", "sample")]); + } + + #[test] + fn collect_explicit_plugin_mentions_ignores_non_plugin_paths() { + let plugins = vec![plugin("sample@test", "sample")]; + + let mentioned = collect_explicit_plugin_mentions( + &[text_input( + "use [$app](app://calendar) and [$skill](skill://team/skill) and [$file](/tmp/file.txt)", + )], &plugins, ); diff --git a/codex-rs/core/src/network_proxy_loader.rs b/codex-rs/core/src/network_proxy_loader.rs index d78cc6f5aa..f6d09a1c23 100644 --- a/codex-rs/core/src/network_proxy_loader.rs +++ b/codex-rs/core/src/network_proxy_loader.rs @@ -311,10 +311,9 @@ allowed_domains = ["lower.example.com"] "#, ) .expect("lower layer should parse"); - let higher_network: toml::Value = toml::from_str( - r#"network = { allowed_domains = ["higher.example.com"] }"#, - ) - .expect("higher layer should parse"); + let higher_network: toml::Value = + toml::from_str(r#"network = { allowed_domains = ["higher.example.com"] }"#) + .expect("higher layer should parse"); let mut config = NetworkProxyConfig::default(); apply_network_tables( diff --git a/codex-rs/core/src/plugins/injection.rs b/codex-rs/core/src/plugins/injection.rs index d8adfc92c6..00b15426fe 100644 --- a/codex-rs/core/src/plugins/injection.rs +++ b/codex-rs/core/src/plugins/injection.rs @@ -19,7 +19,7 @@ pub(crate) fn build_plugin_injections( return Vec::new(); } - // Turn each explicit @plugin mention into a developer hint that points the + // Turn each explicit plugin mention into a developer hint that points the // model at the plugin's visible MCP servers, enabled apps, and skill prefix. mentioned_plugins .iter() diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index 2e45a2b3eb..0938b39ccf 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -66,6 +66,7 @@ pub struct ConfiguredMarketplacePluginSummary { pub struct LoadedPlugin { pub config_name: String, pub manifest_name: Option, + pub manifest_description: Option, pub root: AbsolutePathBuf, pub enabled: bool, pub skill_roots: Vec, @@ -84,6 +85,7 @@ impl LoadedPlugin { pub struct PluginCapabilitySummary { pub config_name: String, pub display_name: String, + pub description: Option, pub has_skills: bool, pub mcp_server_names: Vec, pub app_connector_ids: Vec, @@ -104,6 +106,7 @@ impl PluginCapabilitySummary { .manifest_name .clone() .unwrap_or_else(|| plugin.config_name.clone()), + description: plugin.manifest_description.clone(), has_skills: !plugin.skill_roots.is_empty(), mcp_server_names, app_connector_ids: plugin.apps.clone(), @@ -476,6 +479,7 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore) let mut loaded_plugin = LoadedPlugin { config_name, manifest_name: None, + manifest_description: None, root, enabled: plugin.enabled, skill_roots: Vec::new(), @@ -507,6 +511,7 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore) }; loaded_plugin.manifest_name = Some(plugin_manifest_name(&manifest, plugin_root.as_path())); + loaded_plugin.manifest_description = manifest.description; loaded_plugin.skill_roots = default_skill_roots(plugin_root.as_path()); let mut mcp_servers = HashMap::new(); for mcp_config_path in default_mcp_config_paths(plugin_root.as_path()) { @@ -752,7 +757,10 @@ mod tests { write_file( &plugin_root.join(".codex-plugin/plugin.json"), - r#"{"name":"sample"}"#, + r#"{ + "name": "sample", + "description": "Plugin that includes the sample MCP server and Skills" +}"#, ); write_file( &plugin_root.join("skills/sample-search/SKILL.md"), @@ -792,6 +800,9 @@ mod tests { vec![LoadedPlugin { config_name: "sample@test".to_string(), manifest_name: Some("sample".to_string()), + manifest_description: Some( + "Plugin that includes the sample MCP server and Skills".to_string(), + ), root: AbsolutePathBuf::try_from(plugin_root.clone()).unwrap(), enabled: true, skill_roots: vec![plugin_root.join("skills")], @@ -819,6 +830,19 @@ mod tests { error: None, }] ); + assert_eq!( + outcome.capability_summaries(), + &[PluginCapabilitySummary { + config_name: "sample@test".to_string(), + display_name: "sample".to_string(), + description: Some( + "Plugin that includes the sample MCP server and Skills".to_string(), + ), + has_skills: true, + mcp_server_names: vec!["sample".to_string()], + app_connector_ids: vec![AppConnectorId("connector_example".to_string())], + }] + ); assert_eq!( outcome.effective_skill_roots(), vec![plugin_root.join("skills")] @@ -862,6 +886,7 @@ mod tests { vec![LoadedPlugin { config_name: "sample@test".to_string(), manifest_name: None, + manifest_description: None, root: AbsolutePathBuf::try_from(plugin_root).unwrap(), enabled: false, skill_roots: Vec::new(), @@ -972,6 +997,7 @@ mod tests { let plugin = |config_name: &str, dir_name: &str, manifest_name: &str| LoadedPlugin { config_name: config_name.to_string(), manifest_name: Some(manifest_name.to_string()), + manifest_description: None, root: AbsolutePathBuf::try_from(codex_home.path().join(dir_name)).unwrap(), enabled: true, skill_roots: Vec::new(), @@ -982,6 +1008,7 @@ mod tests { let summary = |config_name: &str, display_name: &str| PluginCapabilitySummary { config_name: config_name.to_string(), display_name: display_name.to_string(), + description: None, ..PluginCapabilitySummary::default() }; let outcome = PluginLoadOutcome::from_plugins(vec![ diff --git a/codex-rs/core/src/plugins/manifest.rs b/codex-rs/core/src/plugins/manifest.rs index ad677de814..755db5d501 100644 --- a/codex-rs/core/src/plugins/manifest.rs +++ b/codex-rs/core/src/plugins/manifest.rs @@ -6,7 +6,8 @@ pub(crate) const PLUGIN_MANIFEST_PATH: &str = ".codex-plugin/plugin.json"; #[derive(Debug, Default, Deserialize)] pub(crate) struct PluginManifest { - name: String, + pub(crate) name: String, + pub(crate) description: Option, } pub(crate) fn load_plugin_manifest(plugin_root: &Path) -> Option { diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index 466c974978..df150bf2bc 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -7,6 +7,7 @@ use std::path::Path; use std::path::PathBuf; use chrono::SecondsFormat; +use chrono::Utc; use codex_protocol::ThreadId; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::BaseInstructions; @@ -448,7 +449,6 @@ impl RolloutRecorder { // future will yield, which is fine – we only need to ensure we do not // perform *blocking* I/O on the caller's thread. let (tx, rx) = mpsc::channel::(256); - // Spawn a Tokio task that owns the file handle and performs async // writes. Using `tokio::fs::File` keeps everything on the async I/O // driver instead of blocking the runtime. @@ -614,14 +614,15 @@ impl RolloutRecorder { match self.tx.send(RolloutCmd::Shutdown { ack: tx_done }).await { Ok(_) => rx_done .await - .map_err(|e| IoError::other(format!("failed waiting for rollout shutdown: {e}"))), + .map_err(|e| IoError::other(format!("failed waiting for rollout shutdown: {e}")))?, Err(e) => { warn!("failed to send rollout shutdown command: {e}"); - Err(IoError::other(format!( + return Err(IoError::other(format!( "failed to send rollout shutdown command: {e}" - ))) + ))); } - } + }; + Ok(()) } } @@ -744,25 +745,21 @@ async fn rollout_writer( while let Some(cmd) = rx.recv().await { match cmd { RolloutCmd::AddItems(items) => { - let mut persisted_items = Vec::new(); - for item in items { - persisted_items.push(item); - } - if persisted_items.is_empty() { + if items.is_empty() { continue; } if writer.is_none() { - buffered_items.extend(persisted_items); + buffered_items.extend(items); continue; } write_and_reconcile_items( writer.as_mut(), - persisted_items.as_slice(), + items.as_slice(), &rollout_path, state_db_ctx.as_deref(), - &mut state_builder, + state_builder.as_ref(), default_provider.as_str(), ) .await?; @@ -800,7 +797,7 @@ async fn rollout_writer( buffered_items.as_slice(), &rollout_path, state_db_ctx.as_deref(), - &mut state_builder, + state_builder.as_ref(), default_provider.as_str(), ) .await?; @@ -861,13 +858,12 @@ async fn write_session_meta( if let Some(writer) = writer.as_mut() { writer.write_rollout_item(&rollout_item).await?; } - state_db::reconcile_rollout( + sync_thread_state_after_write( state_db_ctx, rollout_path, - default_provider, state_builder.as_ref(), std::slice::from_ref(&rollout_item), - None, + default_provider, (!generate_memories).then_some("disabled"), ) .await; @@ -879,7 +875,7 @@ async fn write_and_reconcile_items( items: &[RolloutItem], rollout_path: &Path, state_db_ctx: Option<&StateRuntime>, - state_builder: &mut Option, + state_builder: Option<&ThreadMetadataBuilder>, default_provider: &str, ) -> std::io::Result<()> { if let Some(writer) = writer.as_mut() { @@ -887,20 +883,65 @@ async fn write_and_reconcile_items( writer.write_rollout_item(item).await?; } } - if let Some(builder) = state_builder.as_mut() { - builder.rollout_path = rollout_path.to_path_buf(); + sync_thread_state_after_write( + state_db_ctx, + rollout_path, + state_builder, + items, + default_provider, + None, + ) + .await; + Ok(()) +} + +async fn sync_thread_state_after_write( + state_db_ctx: Option<&StateRuntime>, + rollout_path: &Path, + state_builder: Option<&ThreadMetadataBuilder>, + items: &[RolloutItem], + default_provider: &str, + new_thread_memory_mode: Option<&str>, +) { + let updated_at = Utc::now(); + if new_thread_memory_mode.is_some() + || items + .iter() + .any(codex_state::rollout_item_affects_thread_metadata) + { + state_db::apply_rollout_items( + state_db_ctx, + rollout_path, + default_provider, + state_builder, + items, + "rollout_writer", + new_thread_memory_mode, + Some(updated_at), + ) + .await; + return; + } + + let thread_id = state_builder + .map(|builder| builder.id) + .or_else(|| metadata::builder_from_items(items, rollout_path).map(|builder| builder.id)); + if state_db::touch_thread_updated_at(state_db_ctx, thread_id, updated_at, "rollout_writer") + .await + { + return; } state_db::apply_rollout_items( state_db_ctx, rollout_path, default_provider, - state_builder.as_ref(), + state_builder, items, "rollout_writer", - None, + new_thread_memory_mode, + Some(updated_at), ) .await; - Ok(()) } struct JsonlWriter { @@ -1079,6 +1120,7 @@ mod tests { use std::io::Write; use std::path::Path; use std::path::PathBuf; + use std::time::Duration; use tempfile::TempDir; use uuid::Uuid; @@ -1200,6 +1242,151 @@ mod tests { Ok(()) } + #[tokio::test] + async fn metadata_irrelevant_events_touch_state_db_updated_at() -> std::io::Result<()> { + let home = TempDir::new().expect("temp dir"); + let mut config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .build() + .await?; + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow sqlite"); + + let state_db = codex_state::StateRuntime::init( + home.path().to_path_buf(), + config.model_provider_id.clone(), + None, + ) + .await + .expect("state db should initialize"); + state_db + .mark_backfill_complete(None) + .await + .expect("backfill should be complete"); + + let thread_id = ThreadId::new(); + let recorder = RolloutRecorder::new( + &config, + RolloutRecorderParams::new( + thread_id, + None, + SessionSource::Cli, + BaseInstructions::default(), + Vec::new(), + EventPersistenceMode::Limited, + ), + Some(state_db.clone()), + None, + ) + .await?; + + recorder + .record_items(&[RolloutItem::EventMsg(EventMsg::UserMessage( + UserMessageEvent { + message: "first-user-message".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + }, + ))]) + .await?; + recorder.persist().await?; + recorder.flush().await?; + let initial_thread = state_db + .get_thread(thread_id) + .await + .expect("thread should load") + .expect("thread should exist"); + let initial_updated_at = initial_thread.updated_at; + let initial_title = initial_thread.title.clone(); + let initial_first_user_message = initial_thread.first_user_message.clone(); + + tokio::time::sleep(Duration::from_secs(1)).await; + + recorder + .record_items(&[RolloutItem::EventMsg(EventMsg::AgentMessage( + AgentMessageEvent { + message: "assistant text".to_string(), + phase: None, + }, + ))]) + .await?; + recorder.flush().await?; + + let updated_thread = state_db + .get_thread(thread_id) + .await + .expect("thread should load after agent message") + .expect("thread should still exist"); + + assert!(updated_thread.updated_at > initial_updated_at); + assert_eq!(updated_thread.title, initial_title); + assert_eq!( + updated_thread.first_user_message, + initial_first_user_message + ); + + recorder.shutdown().await?; + Ok(()) + } + + #[tokio::test] + async fn metadata_irrelevant_events_fall_back_to_upsert_when_thread_missing() + -> std::io::Result<()> { + let home = TempDir::new().expect("temp dir"); + let mut config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .build() + .await?; + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow sqlite"); + + let state_db = codex_state::StateRuntime::init( + home.path().to_path_buf(), + config.model_provider_id.clone(), + None, + ) + .await + .expect("state db should initialize"); + let thread_id = ThreadId::new(); + let rollout_path = home.path().join("rollout.jsonl"); + let builder = ThreadMetadataBuilder::new( + thread_id, + rollout_path.clone(), + Utc::now(), + SessionSource::Cli, + ); + let items = vec![RolloutItem::EventMsg(EventMsg::AgentMessage( + AgentMessageEvent { + message: "assistant text".to_string(), + phase: None, + }, + ))]; + + sync_thread_state_after_write( + Some(state_db.as_ref()), + rollout_path.as_path(), + Some(&builder), + items.as_slice(), + config.model_provider_id.as_str(), + None, + ) + .await; + + let thread = state_db + .get_thread(thread_id) + .await + .expect("thread should load after fallback") + .expect("thread should be inserted after fallback"); + assert_eq!(thread.id, thread_id); + + Ok(()) + } + #[tokio::test] async fn list_threads_db_disabled_does_not_skip_paginated_items() -> std::io::Result<()> { let home = TempDir::new().expect("temp dir"); diff --git a/codex-rs/core/src/skills/injection.rs b/codex-rs/core/src/skills/injection.rs index e293aac59f..de50d6cddc 100644 --- a/codex-rs/core/src/skills/injection.rs +++ b/codex-rs/core/src/skills/injection.rs @@ -178,12 +178,14 @@ impl<'a> ToolMentions<'a> { pub(crate) enum ToolMentionKind { App, Mcp, + Plugin, Skill, Other, } const APP_PATH_PREFIX: &str = "app://"; const MCP_PATH_PREFIX: &str = "mcp://"; +const PLUGIN_PATH_PREFIX: &str = "plugin://"; const SKILL_PATH_PREFIX: &str = "skill://"; const SKILL_FILENAME: &str = "SKILL.md"; @@ -192,6 +194,8 @@ pub(crate) fn tool_kind_for_path(path: &str) -> ToolMentionKind { ToolMentionKind::App } else if path.starts_with(MCP_PATH_PREFIX) { ToolMentionKind::Mcp + } else if path.starts_with(PLUGIN_PATH_PREFIX) { + ToolMentionKind::Plugin } else if path.starts_with(SKILL_PATH_PREFIX) || is_skill_filename(path) { ToolMentionKind::Skill } else { @@ -209,6 +213,11 @@ pub(crate) fn app_id_from_path(path: &str) -> Option<&str> { .filter(|value| !value.is_empty()) } +pub(crate) fn plugin_config_name_from_path(path: &str) -> Option<&str> { + path.strip_prefix(PLUGIN_PATH_PREFIX) + .filter(|value| !value.is_empty()) +} + pub(crate) fn normalize_skill_path(path: &str) -> &str { path.strip_prefix(SKILL_PATH_PREFIX).unwrap_or(path) } @@ -219,6 +228,10 @@ pub(crate) fn normalize_skill_path(path: &str) -> &str { /// resource path is present, it is captured for exact path matching while also tracking /// the name for fallback matching. pub(crate) fn extract_tool_mentions(text: &str) -> ToolMentions<'_> { + extract_tool_mentions_with_sigil(text, '$') +} + +fn extract_tool_mentions_with_sigil(text: &str, sigil: char) -> ToolMentions<'_> { let text_bytes = text.as_bytes(); let mut mentioned_names: HashSet<&str> = HashSet::new(); let mut mentioned_paths: HashSet<&str> = HashSet::new(); @@ -229,11 +242,13 @@ pub(crate) fn extract_tool_mentions(text: &str) -> ToolMentions<'_> { let byte = text_bytes[index]; if byte == b'[' && let Some((name, path, end_index)) = - parse_linked_tool_mention(text, text_bytes, index) + parse_linked_tool_mention(text, text_bytes, index, sigil) { if !is_common_env_var(name) { - let kind = tool_kind_for_path(path); - if !matches!(kind, ToolMentionKind::App | ToolMentionKind::Mcp) { + if !matches!( + tool_kind_for_path(path), + ToolMentionKind::App | ToolMentionKind::Mcp | ToolMentionKind::Plugin + ) { mentioned_names.insert(name); } mentioned_paths.insert(path); @@ -242,7 +257,7 @@ pub(crate) fn extract_tool_mentions(text: &str) -> ToolMentions<'_> { continue; } - if byte != b'$' { + if byte != sigil as u8 { index += 1; continue; } @@ -297,7 +312,7 @@ fn select_skills_from_mentions( .filter(|path| { !matches!( tool_kind_for_path(path), - ToolMentionKind::App | ToolMentionKind::Mcp + ToolMentionKind::App | ToolMentionKind::Mcp | ToolMentionKind::Plugin ) }) .map(normalize_skill_path) @@ -361,13 +376,14 @@ fn parse_linked_tool_mention<'a>( text: &'a str, text_bytes: &[u8], start: usize, + sigil: char, ) -> Option<(&'a str, &'a str, usize)> { - let dollar_index = start + 1; - if text_bytes.get(dollar_index) != Some(&b'$') { + let sigil_index = start + 1; + if text_bytes.get(sigil_index) != Some(&(sigil as u8)) { return None; } - let name_start = dollar_index + 1; + let name_start = sigil_index + 1; let first_name_byte = text_bytes.get(name_start)?; if !is_mention_name_char(*first_name_byte) { return None; diff --git a/codex-rs/core/src/state_db.rs b/codex-rs/core/src/state_db.rs index 30140eb1c5..2a9fa5e451 100644 --- a/codex-rs/core/src/state_db.rs +++ b/codex-rs/core/src/state_db.rs @@ -361,6 +361,7 @@ pub async fn reconcile_rollout( items, "reconcile_rollout", new_thread_memory_mode, + None, ) .await; return; @@ -491,6 +492,7 @@ pub async fn read_repair_rollout_path( } /// Apply rollout items incrementally to SQLite. +#[allow(clippy::too_many_arguments)] pub async fn apply_rollout_items( context: Option<&codex_state::StateRuntime>, rollout_path: &Path, @@ -499,6 +501,7 @@ pub async fn apply_rollout_items( items: &[RolloutItem], stage: &str, new_thread_memory_mode: Option<&str>, + updated_at_override: Option>, ) { let Some(ctx) = context else { return; @@ -520,7 +523,13 @@ pub async fn apply_rollout_items( builder.rollout_path = rollout_path.to_path_buf(); builder.cwd = normalize_cwd_for_state_db(&builder.cwd); if let Err(err) = ctx - .apply_rollout_items(&builder, items, None, new_thread_memory_mode) + .apply_rollout_items( + &builder, + items, + None, + new_thread_memory_mode, + updated_at_override, + ) .await { warn!( @@ -530,6 +539,26 @@ pub async fn apply_rollout_items( } } +pub async fn touch_thread_updated_at( + context: Option<&codex_state::StateRuntime>, + thread_id: Option, + updated_at: DateTime, + stage: &str, +) -> bool { + let Some(ctx) = context else { + return false; + }; + let Some(thread_id) = thread_id else { + return false; + }; + ctx.touch_thread_updated_at(thread_id, updated_at) + .await + .unwrap_or_else(|err| { + warn!("state db touch_thread_updated_at failed during {stage} for {thread_id}: {err}"); + false + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index a8feeb5133..f6962edfd7 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -7,6 +7,7 @@ mod user_shell; use std::sync::Arc; use std::time::Duration; +use std::time::Instant; use async_trait::async_trait; use tokio::select; @@ -25,6 +26,7 @@ use crate::contextual_user_message::TURN_ABORTED_OPEN_TAG; use crate::event_mapping::parse_turn_item; use crate::models_manager::manager::ModelsManager; use crate::protocol::EventMsg; +use crate::protocol::TokenUsage; use crate::protocol::TurnAbortReason; use crate::protocol::TurnAbortedEvent; use crate::protocol::TurnCompleteEvent; @@ -131,10 +133,21 @@ impl Session { let task: Arc = Arc::new(task); let task_kind = task.kind(); let span_name = task.span_name(); + let started_at = Instant::now(); + turn_context + .turn_timing_state + .mark_turn_started(started_at) + .await; + let token_usage_at_turn_start = self.total_token_usage().await.unwrap_or_default(); let cancellation_token = CancellationToken::new(); let done = Arc::new(Notify::new()); + let timer = turn_context + .otel_manager + .start_timer("codex.turn.e2e_duration_ms", &[]) + .ok(); + let done_clone = Arc::clone(&done); let handle = { let session_ctx = Arc::new(SessionTaskContext::new(Arc::clone(self))); @@ -174,11 +187,6 @@ impl Session { ) }; - let timer = turn_context - .otel_manager - .start_timer("codex.turn.e2e_duration_ms", &[]) - .ok(); - let running_task = RunningTask { done, handle: Arc::new(AbortOnDropHandle::new(handle)), @@ -188,7 +196,8 @@ impl Session { turn_context: Arc::clone(&turn_context), _timer: timer, }; - self.register_new_active_task(running_task).await; + self.register_new_active_task(running_task, token_usage_at_turn_start) + .await; } pub async fn abort_all_tasks(self: &Arc, reason: TurnAbortReason) { @@ -319,11 +328,16 @@ impl Session { self.send_event(turn_context.as_ref(), event).await; } - async fn register_new_active_task(&self, task: RunningTask) { - let token_usage_at_turn_start = self.total_token_usage().await.unwrap_or_default(); + async fn register_new_active_task( + &self, + task: RunningTask, + token_usage_at_turn_start: TokenUsage, + ) { let mut active = self.active_turn.lock().await; let mut turn = ActiveTurn::default(); - turn.turn_state.lock().await.token_usage_at_turn_start = token_usage_at_turn_start; + let mut turn_state = turn.turn_state.lock().await; + turn_state.token_usage_at_turn_start = token_usage_at_turn_start; + drop(turn_state); turn.add_task(task); *active = Some(turn); } diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index efc596ea36..cea0b9dd33 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -342,9 +342,7 @@ impl ShellHandler { .map_err(FunctionCallError::RespondToModel)?; // Approval policy guard for explicit escalation in non-OnRequest modes. - if exec_params - .sandbox_permissions - .requires_additional_permissions() + if exec_params.sandbox_permissions.requests_sandbox_override() && !matches!( turn.approval_policy.value(), codex_protocol::protocol::AskForApproval::OnRequest diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index a04c5883c6..915c1734c5 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -171,7 +171,7 @@ impl ToolHandler for UnifiedExecHandler { let request_permission_enabled = session.features().enabled(Feature::RequestPermissions); - if sandbox_permissions.requires_additional_permissions() + if sandbox_permissions.requests_sandbox_override() && !matches!( context.turn.approval_policy.value(), codex_protocol::protocol::AskForApproval::OnRequest diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 4e4510ddea..f0c952e078 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -684,7 +684,7 @@ fn create_collab_input_items_schema() -> JsonSchema { "path".to_string(), JsonSchema::String { description: Some( - "Path when type is local_image/skill, or mention target such as app:// when type is mention." + "Path when type is local_image/skill, or structured mention target such as app:// or plugin://@ when type is mention." .to_string(), ), }, diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs new file mode 100644 index 0000000000..b825fde948 --- /dev/null +++ b/codex-rs/core/src/turn_timing.rs @@ -0,0 +1,283 @@ +use std::time::Duration; +use std::time::Instant; + +use codex_protocol::items::TurnItem; +use codex_protocol::models::ResponseItem; +use tokio::sync::Mutex; + +use crate::ResponseEvent; +use crate::codex::TurnContext; +use crate::stream_events_utils::raw_assistant_output_text_from_item; + +const TURN_TTFT_DURATION_METRIC: &str = "codex.turn.ttft.duration_ms"; +const TURN_TTFM_DURATION_METRIC: &str = "codex.turn.ttfm.duration_ms"; + +pub(crate) async fn record_turn_ttft_metric(turn_context: &TurnContext, event: &ResponseEvent) { + let Some(duration) = turn_context + .turn_timing_state + .record_ttft_for_response_event(event) + .await + else { + return; + }; + turn_context + .otel_manager + .record_duration(TURN_TTFT_DURATION_METRIC, duration, &[]); +} + +pub(crate) async fn record_turn_ttfm_metric(turn_context: &TurnContext, item: &TurnItem) { + let Some(duration) = turn_context + .turn_timing_state + .record_ttfm_for_turn_item(item) + .await + else { + return; + }; + turn_context + .otel_manager + .record_duration(TURN_TTFM_DURATION_METRIC, duration, &[]); +} + +#[derive(Debug, Default)] +pub(crate) struct TurnTimingState { + state: Mutex, +} + +#[derive(Debug, Default)] +struct TurnTimingStateInner { + started_at: Option, + first_token_at: Option, + first_message_at: Option, +} + +impl TurnTimingState { + pub(crate) async fn mark_turn_started(&self, started_at: Instant) { + let mut state = self.state.lock().await; + state.started_at = Some(started_at); + state.first_token_at = None; + state.first_message_at = None; + } + + pub(crate) async fn record_ttft_for_response_event( + &self, + event: &ResponseEvent, + ) -> Option { + if !response_event_records_turn_ttft(event) { + return None; + } + let mut state = self.state.lock().await; + state.record_turn_ttft() + } + + pub(crate) async fn record_ttfm_for_turn_item(&self, item: &TurnItem) -> Option { + if !matches!(item, TurnItem::AgentMessage(_)) { + return None; + } + let mut state = self.state.lock().await; + state.record_turn_ttfm() + } +} + +impl TurnTimingStateInner { + fn record_turn_ttft(&mut self) -> Option { + if self.first_token_at.is_some() { + return None; + } + let started_at = self.started_at?; + let first_token_at = Instant::now(); + self.first_token_at = Some(first_token_at); + Some(first_token_at.duration_since(started_at)) + } + + fn record_turn_ttfm(&mut self) -> Option { + if self.first_message_at.is_some() { + return None; + } + let started_at = self.started_at?; + let first_message_at = Instant::now(); + self.first_message_at = Some(first_message_at); + Some(first_message_at.duration_since(started_at)) + } +} + +fn response_event_records_turn_ttft(event: &ResponseEvent) -> bool { + match event { + ResponseEvent::OutputItemDone(item) | ResponseEvent::OutputItemAdded(item) => { + response_item_records_turn_ttft(item) + } + ResponseEvent::OutputTextDelta(_) + | ResponseEvent::ReasoningSummaryDelta { .. } + | ResponseEvent::ReasoningContentDelta { .. } => true, + ResponseEvent::Created + | ResponseEvent::ServerModel(_) + | ResponseEvent::ServerReasoningIncluded(_) + | ResponseEvent::Completed { .. } + | ResponseEvent::ReasoningSummaryPartAdded { .. } + | ResponseEvent::RateLimits(_) + | ResponseEvent::ModelsEtag(_) => false, + } +} + +fn response_item_records_turn_ttft(item: &ResponseItem) -> bool { + match item { + ResponseItem::Message { .. } => { + raw_assistant_output_text_from_item(item).is_some_and(|text| !text.is_empty()) + } + ResponseItem::Reasoning { + summary, content, .. + } => { + summary.iter().any(|entry| match entry { + codex_protocol::models::ReasoningItemReasoningSummary::SummaryText { text } => { + !text.is_empty() + } + }) || content.as_ref().is_some_and(|entries| { + entries.iter().any(|entry| match entry { + codex_protocol::models::ReasoningItemContent::ReasoningText { text } + | codex_protocol::models::ReasoningItemContent::Text { text } => { + !text.is_empty() + } + }) + }) + } + ResponseItem::LocalShellCall { .. } + | ResponseItem::FunctionCall { .. } + | ResponseItem::CustomToolCall { .. } + | ResponseItem::WebSearchCall { .. } + | ResponseItem::ImageGenerationCall { .. } + | ResponseItem::GhostSnapshot { .. } + | ResponseItem::Compaction { .. } => true, + ResponseItem::FunctionCallOutput { .. } + | ResponseItem::CustomToolCallOutput { .. } + | ResponseItem::Other => false, + } +} + +#[cfg(test)] +mod tests { + use codex_protocol::items::AgentMessageItem; + use codex_protocol::items::TurnItem; + use codex_protocol::models::ContentItem; + use codex_protocol::models::FunctionCallOutputPayload; + use codex_protocol::models::ResponseItem; + use pretty_assertions::assert_eq; + use std::time::Instant; + + use super::TurnTimingState; + use super::response_item_records_turn_ttft; + use crate::ResponseEvent; + + #[tokio::test] + async fn turn_timing_state_records_ttft_only_once_per_turn() { + let state = TurnTimingState::default(); + assert_eq!( + state + .record_ttft_for_response_event(&ResponseEvent::OutputTextDelta("hi".to_string())) + .await, + None + ); + + state.mark_turn_started(Instant::now()).await; + assert_eq!( + state + .record_ttft_for_response_event(&ResponseEvent::Created) + .await, + None + ); + assert!( + state + .record_ttft_for_response_event(&ResponseEvent::OutputTextDelta("hi".to_string())) + .await + .is_some() + ); + assert_eq!( + state + .record_ttft_for_response_event(&ResponseEvent::OutputTextDelta( + "again".to_string() + )) + .await, + None + ); + } + + #[tokio::test] + async fn turn_timing_state_records_ttfm_independently_of_ttft() { + let state = TurnTimingState::default(); + state.mark_turn_started(Instant::now()).await; + + assert!( + state + .record_ttft_for_response_event(&ResponseEvent::OutputTextDelta("hi".to_string())) + .await + .is_some() + ); + assert!( + state + .record_ttfm_for_turn_item(&TurnItem::AgentMessage(AgentMessageItem { + id: "msg-1".to_string(), + content: Vec::new(), + phase: None, + })) + .await + .is_some() + ); + assert_eq!( + state + .record_ttfm_for_turn_item(&TurnItem::AgentMessage(AgentMessageItem { + id: "msg-2".to_string(), + content: Vec::new(), + phase: None, + })) + .await, + None + ); + } + + #[test] + fn response_item_records_turn_ttft_for_first_output_signals() { + assert!(response_item_records_turn_ttft( + &ResponseItem::FunctionCall { + id: None, + name: "shell".to_string(), + arguments: "{}".to_string(), + call_id: "call-1".to_string(), + } + )); + assert!(response_item_records_turn_ttft( + &ResponseItem::CustomToolCall { + id: None, + status: None, + call_id: "call-2".to_string(), + name: "custom".to_string(), + input: "echo hi".to_string(), + } + )); + assert!(response_item_records_turn_ttft(&ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "hello".to_string(), + }], + end_turn: None, + phase: None, + })); + } + + #[test] + fn response_item_records_turn_ttft_ignores_empty_non_output_items() { + assert!(!response_item_records_turn_ttft(&ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: String::new(), + }], + end_turn: None, + phase: None, + })); + assert!(!response_item_records_turn_ttft( + &ResponseItem::FunctionCallOutput { + call_id: "call-1".to_string(), + output: FunctionCallOutputPayload::from_text("ok".to_string()), + } + )); + } +} diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 622bb74a21..50bb174291 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -239,7 +239,7 @@ fn shell_event_with_prefix_rule( "command": command, "timeout_ms": timeout_ms, }); - if sandbox_permissions.requires_additional_permissions() { + if sandbox_permissions.requests_sandbox_override() { args["sandbox_permissions"] = json!(sandbox_permissions); } if let Some(prefix_rule) = prefix_rule { @@ -262,7 +262,7 @@ fn exec_command_event( if let Some(yield_time_ms) = yield_time_ms { args["yield_time_ms"] = json!(yield_time_ms); } - if sandbox_permissions.requires_additional_permissions() { + if sandbox_permissions.requests_sandbox_override() { args["sandbox_permissions"] = json!(sandbox_permissions); let reason = justification.unwrap_or(DEFAULT_UNIFIED_EXEC_JUSTIFICATION); args["justification"] = json!(reason); diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index d64702bc97..441921cb28 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -243,9 +243,9 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> { codex .submit(Op::UserInput { - items: vec![codex_protocol::user_input::UserInput::Text { - text: "Use @sample for this task.".into(), - text_elements: Vec::new(), + items: vec![codex_protocol::user_input::UserInput::Mention { + name: "sample".into(), + path: format!("plugin://{SAMPLE_PLUGIN_CONFIG_NAME}"), }], final_output_json_schema: None, }) diff --git a/codex-rs/network-proxy/README.md b/codex-rs/network-proxy/README.md index 5189779622..be8a08356b 100644 --- a/codex-rs/network-proxy/README.md +++ b/codex-rs/network-proxy/README.md @@ -36,6 +36,8 @@ mitm = false # CA cert/key are managed internally under $CODEX_HOME/proxy/ (ca.pem + ca.key). # Hosts must match the allowlist (unless denied). +# Use exact hosts or scoped wildcards like `*.openai.com` or `**.openai.com`. +# The global `*` wildcard is rejected. # If `allowed_domains` is empty, the proxy blocks requests until an allowlist is configured. allowed_domains = ["*.openai.com", "localhost", "127.0.0.1", "::1"] denied_domains = ["evil.example"] @@ -185,6 +187,7 @@ This section documents the protections implemented by `codex-network-proxy`, and what it can reasonably guarantee. - Allowlist-first policy: if `allowed_domains` is empty, requests are blocked until an allowlist is configured. +- Domain patterns: exact hosts plus scoped wildcards (`*.example.com`, `**.example.com`) are supported; the global `*` wildcard is rejected. - Deny wins: entries in `denied_domains` always override the allowlist. - Local/private network protection: when `allow_local_binding = false`, the proxy blocks loopback and common private/link-local ranges. Explicit allowlisting of local IP literals (or `localhost`) diff --git a/codex-rs/network-proxy/src/mitm_tests.rs b/codex-rs/network-proxy/src/mitm_tests.rs index 03e429bc2a..d2f7984a70 100644 --- a/codex-rs/network-proxy/src/mitm_tests.rs +++ b/codex-rs/network-proxy/src/mitm_tests.rs @@ -83,7 +83,7 @@ async fn mitm_policy_rejects_host_mismatch() { #[tokio::test] async fn mitm_policy_rechecks_local_private_target_after_connect() { let app_state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["*".to_string()], + allowed_domains: vec!["example.com".to_string()], allow_local_binding: false, ..NetworkProxySettings::default() })); diff --git a/codex-rs/network-proxy/src/policy.rs b/codex-rs/network-proxy/src/policy.rs index d663aea065..fb0305c82b 100644 --- a/codex-rs/network-proxy/src/policy.rs +++ b/codex-rs/network-proxy/src/policy.rs @@ -144,16 +144,26 @@ fn normalize_pattern(pattern: &str) -> String { } } +pub(crate) fn is_global_wildcard_domain_pattern(pattern: &str) -> bool { + let normalized = normalize_pattern(pattern); + expand_domain_pattern(&normalized) + .iter() + .any(|candidate| candidate == "*") +} + pub(crate) fn compile_globset(patterns: &[String]) -> Result { let mut builder = GlobSetBuilder::new(); let mut seen = HashSet::new(); for pattern in patterns { + ensure!( + !is_global_wildcard_domain_pattern(pattern), + "unsupported global wildcard domain pattern \"*\"; use exact hosts or scoped wildcards like *.example.com or **.example.com" + ); let pattern = normalize_pattern(pattern); // Supported domain patterns: // - "example.com": match the exact host // - "*.example.com": match any subdomain (not the apex) // - "**.example.com": match the apex and any subdomain - // - "*": match any host for candidate in expand_domain_pattern(&pattern) { if !seen.insert(candidate.clone()) { continue; @@ -170,7 +180,6 @@ pub(crate) fn compile_globset(patterns: &[String]) -> Result { #[derive(Debug, Clone)] pub(crate) enum DomainPattern { - Any, ApexAndSubdomains(String), SubdomainsOnly(String), Exact(String), @@ -186,9 +195,7 @@ impl DomainPattern { if input.is_empty() { return Self::Exact(String::new()); } - if input == "*" { - Self::Any - } else if let Some(domain) = input.strip_prefix("**.") { + if let Some(domain) = input.strip_prefix("**.") { Self::parse_domain(domain, Self::ApexAndSubdomains) } else if let Some(domain) = input.strip_prefix("*.") { Self::parse_domain(domain, Self::SubdomainsOnly) @@ -203,9 +210,6 @@ impl DomainPattern { if input.is_empty() { return Self::Exact(String::new()); } - if input == "*" { - return Self::Any; - } if let Some(domain) = input.strip_prefix("**.") { return Self::ApexAndSubdomains(parse_domain_for_constraints(domain)); } @@ -225,13 +229,11 @@ impl DomainPattern { pub(crate) fn allows(&self, candidate: &DomainPattern) -> bool { match self { - DomainPattern::Any => true, DomainPattern::Exact(domain) => match candidate { DomainPattern::Exact(candidate) => domain_eq(candidate, domain), _ => false, }, DomainPattern::SubdomainsOnly(domain) => match candidate { - DomainPattern::Any => false, DomainPattern::Exact(candidate) => is_strict_subdomain(candidate, domain), DomainPattern::SubdomainsOnly(candidate) => { is_subdomain_or_equal(candidate, domain) @@ -241,7 +243,6 @@ impl DomainPattern { } }, DomainPattern::ApexAndSubdomains(domain) => match candidate { - DomainPattern::Any => false, DomainPattern::Exact(candidate) => is_subdomain_or_equal(candidate, domain), DomainPattern::SubdomainsOnly(candidate) => { is_subdomain_or_equal(candidate, domain) @@ -275,7 +276,6 @@ fn parse_domain_for_constraints(domain: &str) -> String { fn expand_domain_pattern(pattern: &str) -> Vec { match DomainPattern::parse(pattern) { - DomainPattern::Any => vec![pattern.to_string()], DomainPattern::Exact(domain) => vec![domain], DomainPattern::SubdomainsOnly(domain) => { vec![format!("?*.{domain}")] diff --git a/codex-rs/network-proxy/src/runtime.rs b/codex-rs/network-proxy/src/runtime.rs index 7e963f7128..b825495809 100644 --- a/codex-rs/network-proxy/src/runtime.rs +++ b/codex-rs/network-proxy/src/runtime.rs @@ -821,6 +821,7 @@ mod tests { use crate::config::NetworkProxySettings; use crate::policy::compile_globset; use crate::state::NetworkProxyConstraints; + use crate::state::build_config_state; use crate::state::validate_policy_against_constraints; use pretty_assertions::assert_eq; @@ -1014,34 +1015,6 @@ mod tests { ); } - #[tokio::test] - async fn host_blocked_rejects_loopback_when_allowlist_is_wildcard() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["*".to_string()], - allow_local_binding: false, - ..NetworkProxySettings::default() - }); - - assert_eq!( - state.host_blocked("127.0.0.1", 80).await.unwrap(), - HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal) - ); - } - - #[tokio::test] - async fn host_blocked_rejects_private_ip_literal_when_allowlist_is_wildcard() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["*".to_string()], - allow_local_binding: false, - ..NetworkProxySettings::default() - }); - - assert_eq!( - state.host_blocked("10.0.0.1", 80).await.unwrap(), - HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal) - ); - } - #[tokio::test] async fn host_blocked_allows_loopback_when_explicitly_allowlisted_and_local_binding_disabled() { let state = network_proxy_state_for_policy(NetworkProxySettings { @@ -1198,6 +1171,62 @@ mod tests { assert!(validate_policy_against_constraints(&config, &constraints).is_err()); } + #[test] + fn validate_policy_against_constraints_rejects_global_wildcard_in_managed_allowlist() { + let constraints = NetworkProxyConstraints { + allowed_domains: Some(vec!["*".to_string()]), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network: NetworkProxySettings { + enabled: true, + allowed_domains: vec!["api.example.com".to_string()], + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_err()); + } + + #[test] + fn validate_policy_against_constraints_rejects_bracketed_global_wildcard_in_managed_allowlist() + { + let constraints = NetworkProxyConstraints { + allowed_domains: Some(vec!["[*]".to_string()]), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network: NetworkProxySettings { + enabled: true, + allowed_domains: vec!["api.example.com".to_string()], + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_err()); + } + + #[test] + fn validate_policy_against_constraints_rejects_double_wildcard_bracketed_global_wildcard_in_managed_allowlist() + { + let constraints = NetworkProxyConstraints { + allowed_domains: Some(vec!["**.[*]".to_string()]), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network: NetworkProxySettings { + enabled: true, + allowed_domains: vec!["api.example.com".to_string()], + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_err()); + } + #[test] fn validate_policy_against_constraints_requires_managed_denied_domains_entries() { let constraints = NetworkProxyConstraints { @@ -1349,11 +1378,21 @@ mod tests { } #[test] - fn compile_globset_matches_all_with_star() { + fn compile_globset_rejects_global_wildcard() { let patterns = vec!["*".to_string()]; - let set = compile_globset(&patterns).unwrap(); - assert!(set.is_match("openai.com")); - assert!(set.is_match("api.openai.com")); + assert!(compile_globset(&patterns).is_err()); + } + + #[test] + fn compile_globset_rejects_bracketed_global_wildcard() { + let patterns = vec!["[*]".to_string()]; + assert!(compile_globset(&patterns).is_err()); + } + + #[test] + fn compile_globset_rejects_double_wildcard_bracketed_global_wildcard() { + let patterns = vec!["**.[*]".to_string()]; + assert!(compile_globset(&patterns).is_err()); } #[test] @@ -1371,6 +1410,60 @@ mod tests { assert!(compile_globset(&patterns).is_err()); } + #[test] + fn build_config_state_rejects_global_wildcard_allowed_domains() { + let config = NetworkProxyConfig { + network: NetworkProxySettings { + enabled: true, + allowed_domains: vec!["*".to_string()], + ..NetworkProxySettings::default() + }, + }; + + assert!(build_config_state(config, NetworkProxyConstraints::default()).is_err()); + } + + #[test] + fn build_config_state_rejects_bracketed_global_wildcard_allowed_domains() { + let config = NetworkProxyConfig { + network: NetworkProxySettings { + enabled: true, + allowed_domains: vec!["[*]".to_string()], + ..NetworkProxySettings::default() + }, + }; + + assert!(build_config_state(config, NetworkProxyConstraints::default()).is_err()); + } + + #[test] + fn build_config_state_rejects_global_wildcard_denied_domains() { + let config = NetworkProxyConfig { + network: NetworkProxySettings { + enabled: true, + allowed_domains: vec!["example.com".to_string()], + denied_domains: vec!["*".to_string()], + ..NetworkProxySettings::default() + }, + }; + + assert!(build_config_state(config, NetworkProxyConstraints::default()).is_err()); + } + + #[test] + fn build_config_state_rejects_bracketed_global_wildcard_denied_domains() { + let config = NetworkProxyConfig { + network: NetworkProxySettings { + enabled: true, + allowed_domains: vec!["example.com".to_string()], + denied_domains: vec!["[*]".to_string()], + ..NetworkProxySettings::default() + }, + }; + + assert!(build_config_state(config, NetworkProxyConstraints::default()).is_err()); + } + #[cfg(target_os = "macos")] #[tokio::test] async fn unix_socket_allowlist_is_respected_on_macos() { diff --git a/codex-rs/network-proxy/src/state.rs b/codex-rs/network-proxy/src/state.rs index f685b7efb7..fb8898f614 100644 --- a/codex-rs/network-proxy/src/state.rs +++ b/codex-rs/network-proxy/src/state.rs @@ -3,6 +3,7 @@ use crate::config::NetworkProxyConfig; use crate::mitm::MitmState; use crate::policy::DomainPattern; use crate::policy::compile_globset; +use crate::policy::is_global_wildcard_domain_pattern; use crate::runtime::ConfigState; use serde::Deserialize; use std::collections::HashSet; @@ -56,6 +57,10 @@ pub fn build_config_state( constraints: NetworkProxyConstraints, ) -> anyhow::Result { crate::config::validate_unix_socket_allowlist_paths(&config)?; + validate_domain_patterns("network.allowed_domains", &config.network.allowed_domains) + .map_err(NetworkProxyConstraintError::into_anyhow)?; + validate_domain_patterns("network.denied_domains", &config.network.denied_domains) + .map_err(NetworkProxyConstraintError::into_anyhow)?; let deny_set = compile_globset(&config.network.denied_domains)?; let allow_set = compile_globset(&config.network.allowed_domains)?; let mitm = if config.network.mitm { @@ -100,6 +105,8 @@ pub fn validate_policy_against_constraints( } let enabled = config.network.enabled; + validate_domain_patterns("network.allowed_domains", &config.network.allowed_domains)?; + validate_domain_patterns("network.denied_domains", &config.network.denied_domains)?; if let Some(max_enabled) = constraints.enabled { validate(enabled, move |candidate| { if *candidate && !max_enabled { @@ -199,6 +206,7 @@ pub fn validate_policy_against_constraints( } if let Some(allowed_domains) = &constraints.allowed_domains { + validate_domain_patterns("network.allowed_domains", allowed_domains)?; let managed_patterns: Vec = allowed_domains .iter() .map(|entry| DomainPattern::parse_for_constraints(entry)) @@ -227,6 +235,7 @@ pub fn validate_policy_against_constraints( } if let Some(denied_domains) = &constraints.denied_domains { + validate_domain_patterns("network.denied_domains", denied_domains)?; let required_set: HashSet = denied_domains .iter() .map(|s| s.to_ascii_lowercase()) @@ -281,6 +290,24 @@ pub fn validate_policy_against_constraints( Ok(()) } +fn validate_domain_patterns( + field_name: &'static str, + patterns: &[String], +) -> Result<(), NetworkProxyConstraintError> { + if let Some(pattern) = patterns + .iter() + .find(|pattern| is_global_wildcard_domain_pattern(pattern)) + { + return Err(NetworkProxyConstraintError::InvalidValue { + field_name, + candidate: pattern.trim().to_string(), + allowed: "exact hosts or scoped wildcards like *.example.com or **.example.com" + .to_string(), + }); + } + Ok(()) +} + #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum NetworkProxyConstraintError { #[error("invalid value for {field_name}: {candidate} (allowed {allowed})")] diff --git a/codex-rs/otel/src/metrics/names.rs b/codex-rs/otel/src/metrics/names.rs index 76b46d2450..932f94759b 100644 --- a/codex-rs/otel/src/metrics/names.rs +++ b/codex-rs/otel/src/metrics/names.rs @@ -20,3 +20,5 @@ pub(crate) const RESPONSES_API_ENGINE_IAPI_TBT_DURATION_METRIC: &str = "codex.responses_api_engine_iapi_tbt.duration_ms"; pub(crate) const RESPONSES_API_ENGINE_SERVICE_TBT_DURATION_METRIC: &str = "codex.responses_api_engine_service_tbt.duration_ms"; +pub(crate) const TURN_TTFT_DURATION_METRIC: &str = "codex.turn.ttft.duration_ms"; +pub(crate) const TURN_TTFM_DURATION_METRIC: &str = "codex.turn.ttfm.duration_ms"; diff --git a/codex-rs/otel/src/metrics/runtime_metrics.rs b/codex-rs/otel/src/metrics/runtime_metrics.rs index dcac367d87..93f457a61c 100644 --- a/codex-rs/otel/src/metrics/runtime_metrics.rs +++ b/codex-rs/otel/src/metrics/runtime_metrics.rs @@ -10,6 +10,8 @@ use crate::metrics::names::SSE_EVENT_COUNT_METRIC; use crate::metrics::names::SSE_EVENT_DURATION_METRIC; use crate::metrics::names::TOOL_CALL_COUNT_METRIC; use crate::metrics::names::TOOL_CALL_DURATION_METRIC; +use crate::metrics::names::TURN_TTFM_DURATION_METRIC; +use crate::metrics::names::TURN_TTFT_DURATION_METRIC; use crate::metrics::names::WEBSOCKET_EVENT_COUNT_METRIC; use crate::metrics::names::WEBSOCKET_EVENT_DURATION_METRIC; use crate::metrics::names::WEBSOCKET_REQUEST_COUNT_METRIC; @@ -49,6 +51,8 @@ pub struct RuntimeMetricsSummary { pub responses_api_engine_service_ttft_ms: u64, pub responses_api_engine_iapi_tbt_ms: u64, pub responses_api_engine_service_tbt_ms: u64, + pub turn_ttft_ms: u64, + pub turn_ttfm_ms: u64, } impl RuntimeMetricsSummary { @@ -64,6 +68,8 @@ impl RuntimeMetricsSummary { && self.responses_api_engine_service_ttft_ms == 0 && self.responses_api_engine_iapi_tbt_ms == 0 && self.responses_api_engine_service_tbt_ms == 0 + && self.turn_ttft_ms == 0 + && self.turn_ttfm_ms == 0 } pub fn merge(&mut self, other: Self) { @@ -90,6 +96,12 @@ impl RuntimeMetricsSummary { if other.responses_api_engine_service_tbt_ms > 0 { self.responses_api_engine_service_tbt_ms = other.responses_api_engine_service_tbt_ms; } + if other.turn_ttft_ms > 0 { + self.turn_ttft_ms = other.turn_ttft_ms; + } + if other.turn_ttfm_ms > 0 { + self.turn_ttfm_ms = other.turn_ttfm_ms; + } } pub fn responses_api_summary(&self) -> RuntimeMetricsSummary { @@ -137,6 +149,8 @@ impl RuntimeMetricsSummary { sum_histogram_ms(snapshot, RESPONSES_API_ENGINE_IAPI_TBT_DURATION_METRIC); let responses_api_engine_service_tbt_ms = sum_histogram_ms(snapshot, RESPONSES_API_ENGINE_SERVICE_TBT_DURATION_METRIC); + let turn_ttft_ms = sum_histogram_ms(snapshot, TURN_TTFT_DURATION_METRIC); + let turn_ttfm_ms = sum_histogram_ms(snapshot, TURN_TTFM_DURATION_METRIC); Self { tool_calls, api_calls, @@ -149,6 +163,8 @@ impl RuntimeMetricsSummary { responses_api_engine_service_ttft_ms, responses_api_engine_iapi_tbt_ms, responses_api_engine_service_tbt_ms, + turn_ttft_ms, + turn_ttfm_ms, } } } diff --git a/codex-rs/otel/tests/suite/runtime_summary.rs b/codex-rs/otel/tests/suite/runtime_summary.rs index 4020e70b45..8857387d51 100644 --- a/codex-rs/otel/tests/suite/runtime_summary.rs +++ b/codex-rs/otel/tests/suite/runtime_summary.rs @@ -74,6 +74,16 @@ fn runtime_metrics_summary_collects_tool_api_and_streaming_metrics() -> Result<( .into(), )))); manager.record_websocket_event(&ws_timing_response, Duration::from_millis(20)); + manager.record_duration( + "codex.turn.ttft.duration_ms", + Duration::from_millis(95), + &[], + ); + manager.record_duration( + "codex.turn.ttfm.duration_ms", + Duration::from_millis(180), + &[], + ); let summary = manager .runtime_metrics_summary() @@ -105,6 +115,8 @@ fn runtime_metrics_summary_collects_tool_api_and_streaming_metrics() -> Result<( responses_api_engine_service_ttft_ms: 233, responses_api_engine_iapi_tbt_ms: 377, responses_api_engine_service_tbt_ms: 399, + turn_ttft_ms: 95, + turn_ttfm_ms: 180, }; assert_eq!(summary, expected); diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index ed73837c52..f211e4eff0 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -28,18 +28,19 @@ use schemars::JsonSchema; use crate::mcp::CallToolResult; -/// Controls whether a command should use the session sandbox or bypass it. +/// Controls the per-command sandbox override requested by a shell-like tool call. #[derive( Debug, Clone, Copy, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS, )] #[serde(rename_all = "snake_case")] pub enum SandboxPermissions { - /// Run with the configured sandbox + /// Run with the turn's configured sandbox policy unchanged. #[default] UseDefault, - /// Request to run outside the sandbox + /// Request to run outside the sandbox. RequireEscalated, - /// Request to run in the sandbox with additional per-command permissions. + /// Request to stay in the sandbox while widening permissions for this + /// command only. WithAdditionalPermissions, } @@ -49,10 +50,17 @@ impl SandboxPermissions { matches!(self, SandboxPermissions::RequireEscalated) } - /// True if SandboxPermissions requires permissions beyond UseDefault - pub fn requires_additional_permissions(self) -> bool { + /// True if SandboxPermissions requests any explicit per-command override + /// beyond `UseDefault`. + pub fn requests_sandbox_override(self) -> bool { !matches!(self, SandboxPermissions::UseDefault) } + + /// True if SandboxPermissions uses the sandboxed per-command permission + /// widening flow. + pub fn uses_additional_permissions(self) -> bool { + matches!(self, SandboxPermissions::WithAdditionalPermissions) + } } #[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS)] @@ -1305,6 +1313,41 @@ mod tests { use std::path::PathBuf; use tempfile::tempdir; + #[test] + fn sandbox_permissions_helpers_match_documented_semantics() { + let cases = [ + (SandboxPermissions::UseDefault, false, false, false), + (SandboxPermissions::RequireEscalated, true, true, false), + ( + SandboxPermissions::WithAdditionalPermissions, + false, + true, + true, + ), + ]; + + for ( + sandbox_permissions, + requires_escalated_permissions, + requests_sandbox_override, + uses_additional_permissions, + ) in cases + { + assert_eq!( + sandbox_permissions.requires_escalated_permissions(), + requires_escalated_permissions + ); + assert_eq!( + sandbox_permissions.requests_sandbox_override(), + requests_sandbox_override + ); + assert_eq!( + sandbox_permissions.uses_additional_permissions(), + uses_additional_permissions + ); + } + } + #[test] fn convert_mcp_content_to_items_preserves_data_urls() { let contents = vec![serde_json::json!({ diff --git a/codex-rs/protocol/src/user_input.rs b/codex-rs/protocol/src/user_input.rs index c1c22dcaf9..4ed112df8d 100644 --- a/codex-rs/protocol/src/user_input.rs +++ b/codex-rs/protocol/src/user_input.rs @@ -32,7 +32,10 @@ pub enum UserInput { name: String, path: std::path::PathBuf, }, - /// Explicit mention selected by the user (name + app://connector id). + /// Explicit structured mention selected by the user. + /// + /// `path` identifies the exact mention target, for example + /// `app://` or `plugin://@`. Mention { name: String, path: String }, } diff --git a/codex-rs/state/BUILD.bazel b/codex-rs/state/BUILD.bazel index b1f7932168..e10ac17c90 100644 --- a/codex-rs/state/BUILD.bazel +++ b/codex-rs/state/BUILD.bazel @@ -3,5 +3,5 @@ load("//:defs.bzl", "codex_rust_crate") codex_rust_crate( name = "state", crate_name = "codex_state", - compile_data = glob(["migrations/**"]), + compile_data = glob(["logs_migrations/**", "migrations/**"]), ) diff --git a/codex-rs/state/logs_migrations/0001_logs.sql b/codex-rs/state/logs_migrations/0001_logs.sql new file mode 100644 index 0000000000..b5315dc821 --- /dev/null +++ b/codex-rs/state/logs_migrations/0001_logs.sql @@ -0,0 +1,21 @@ +CREATE TABLE logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + ts_nanos INTEGER NOT NULL, + level TEXT NOT NULL, + target TEXT NOT NULL, + message TEXT, + module_path TEXT, + file TEXT, + line INTEGER, + thread_id TEXT, + process_uuid TEXT, + estimated_bytes INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX idx_logs_ts ON logs(ts DESC, ts_nanos DESC, id DESC); +CREATE INDEX idx_logs_thread_id ON logs(thread_id); +CREATE INDEX idx_logs_process_uuid ON logs(process_uuid); +CREATE INDEX idx_logs_thread_id_ts ON logs(thread_id, ts DESC, ts_nanos DESC, id DESC); +CREATE INDEX idx_logs_process_uuid_threadless_ts ON logs(process_uuid, ts DESC, ts_nanos DESC, id DESC) +WHERE thread_id IS NULL; diff --git a/codex-rs/state/src/bin/logs_client.rs b/codex-rs/state/src/bin/logs_client.rs index 6b0039ee97..8b7279c738 100644 --- a/codex-rs/state/src/bin/logs_client.rs +++ b/codex-rs/state/src/bin/logs_client.rs @@ -12,13 +12,13 @@ use owo_colors::OwoColorize; #[derive(Debug, Parser)] #[command(name = "codex-state-logs")] -#[command(about = "Tail Codex logs from the state SQLite DB with simple filters")] +#[command(about = "Tail Codex logs from the dedicated logs SQLite DB with simple filters")] struct Args { /// Path to CODEX_HOME. Defaults to $CODEX_HOME or ~/.codex. #[arg(long, env = "CODEX_HOME")] codex_home: Option, - /// Direct path to the SQLite database. Overrides --codex-home. + /// Direct path to the logs SQLite database. Overrides --codex-home. #[arg(long)] db: Option, @@ -113,7 +113,7 @@ fn resolve_db_path(args: &Args) -> anyhow::Result { } let codex_home = args.codex_home.clone().unwrap_or_else(default_codex_home); - Ok(codex_state::state_db_path(codex_home.as_path())) + Ok(codex_state::logs_db_path(codex_home.as_path())) } fn default_codex_home() -> PathBuf { diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index 1a7fb37248..ba425adbec 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -29,6 +29,17 @@ pub fn apply_rollout_item( } } +/// Return whether this rollout item can mutate thread metadata stored in SQLite. +pub fn rollout_item_affects_thread_metadata(item: &RolloutItem) -> bool { + match item { + RolloutItem::SessionMeta(_) | RolloutItem::TurnContext(_) => true, + RolloutItem::EventMsg(EventMsg::TokenCount(_) | EventMsg::UserMessage(_)) => true, + RolloutItem::EventMsg(_) | RolloutItem::ResponseItem(_) | RolloutItem::Compacted(_) => { + false + } + } +} + fn apply_session_meta_from_item(metadata: &mut ThreadMetadata, meta_line: &SessionMetaLine) { if metadata.id != meta_line.meta.id { // Ignore session_meta lines that don't match the canonical thread ID, diff --git a/codex-rs/state/src/lib.rs b/codex-rs/state/src/lib.rs index a4c7172267..84b4462cef 100644 --- a/codex-rs/state/src/lib.rs +++ b/codex-rs/state/src/lib.rs @@ -23,6 +23,7 @@ pub use runtime::StateRuntime; /// /// Most consumers should prefer [`StateRuntime`]. pub use extract::apply_rollout_item; +pub use extract::rollout_item_affects_thread_metadata; pub use model::AgentJob; pub use model::AgentJobCreateParams; pub use model::AgentJobItem; @@ -44,12 +45,16 @@ pub use model::Stage1StartupClaimParams; pub use model::ThreadMetadata; pub use model::ThreadMetadataBuilder; pub use model::ThreadsPage; +pub use runtime::logs_db_filename; +pub use runtime::logs_db_path; pub use runtime::state_db_filename; pub use runtime::state_db_path; /// Environment variable for overriding the SQLite state database home directory. pub const SQLITE_HOME_ENV: &str = "CODEX_SQLITE_HOME"; +pub const LOGS_DB_FILENAME: &str = "logs"; +pub const LOGS_DB_VERSION: u32 = 1; pub const STATE_DB_FILENAME: &str = "state"; pub const STATE_DB_VERSION: u32 = 5; diff --git a/codex-rs/state/src/log_db.rs b/codex-rs/state/src/log_db.rs index 387dc9f4b5..c6f4a97ee1 100644 --- a/codex-rs/state/src/log_db.rs +++ b/codex-rs/state/src/log_db.rs @@ -1,7 +1,7 @@ //! Tracing log export into the state SQLite database. //! //! This module provides a `tracing_subscriber::Layer` that captures events and -//! inserts them into the `logs` table in `state.sqlite`. The writer runs in a +//! inserts them into the dedicated `logs` SQLite database. The writer runs in a //! background task and batches inserts to keep logging overhead low. //! //! ## Usage @@ -41,9 +41,9 @@ use crate::LogEntry; use crate::StateRuntime; const LOG_QUEUE_CAPACITY: usize = 512; -const LOG_BATCH_SIZE: usize = 64; -const LOG_FLUSH_INTERVAL: Duration = Duration::from_millis(250); -const LOG_RETENTION_DAYS: i64 = 90; +const LOG_BATCH_SIZE: usize = 128; +const LOG_FLUSH_INTERVAL: Duration = Duration::from_secs(2); +const LOG_RETENTION_DAYS: i64 = 10; pub struct LogDbLayer { sender: mpsc::Sender, diff --git a/codex-rs/state/src/migrations.rs b/codex-rs/state/src/migrations.rs index 24b310224b..6e7b9d363c 100644 --- a/codex-rs/state/src/migrations.rs +++ b/codex-rs/state/src/migrations.rs @@ -1,3 +1,4 @@ use sqlx::migrate::Migrator; -pub(crate) static MIGRATOR: Migrator = sqlx::migrate!("./migrations"); +pub(crate) static STATE_MIGRATOR: Migrator = sqlx::migrate!("./migrations"); +pub(crate) static LOGS_MIGRATOR: Migrator = sqlx::migrate!("./logs_migrations"); diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index 42d235fc76..a32f041ce1 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -6,6 +6,8 @@ use crate::AgentJobItemStatus; use crate::AgentJobProgress; use crate::AgentJobStatus; use crate::DB_ERROR_METRIC; +use crate::LOGS_DB_FILENAME; +use crate::LOGS_DB_VERSION; use crate::LogEntry; use crate::LogQuery; use crate::LogRow; @@ -17,7 +19,8 @@ use crate::ThreadMetadata; use crate::ThreadMetadataBuilder; use crate::ThreadsPage; use crate::apply_rollout_item; -use crate::migrations::MIGRATOR; +use crate::migrations::LOGS_MIGRATOR; +use crate::migrations::STATE_MIGRATOR; use crate::model::AgentJobRow; use crate::model::ThreadRow; use crate::model::anchor_from_item; @@ -37,6 +40,7 @@ use sqlx::Row; use sqlx::Sqlite; use sqlx::SqliteConnection; use sqlx::SqlitePool; +use sqlx::migrate::Migrator; use sqlx::sqlite::SqliteConnectOptions; use sqlx::sqlite::SqliteJournalMode; use sqlx::sqlite::SqlitePoolOptions; @@ -61,28 +65,48 @@ mod threads; // - one bucket per threadless (thread_id IS NULL) non-null process_uuid // - one bucket for threadless rows with process_uuid IS NULL const LOG_PARTITION_SIZE_LIMIT_BYTES: i64 = 10 * 1024 * 1024; +const LOG_PARTITION_ROW_LIMIT: i64 = 1_000; #[derive(Clone)] pub struct StateRuntime { codex_home: PathBuf, default_provider: String, pool: Arc, + logs_pool: Arc, } impl StateRuntime { /// Initialize the state runtime using the provided Codex home and default provider. /// - /// This opens (and migrates) the SQLite database at `codex_home/state.sqlite`. + /// This opens (and migrates) the SQLite databases under `codex_home`, + /// keeping logs in a dedicated file to reduce lock contention with the + /// rest of the state store. pub async fn init( codex_home: PathBuf, default_provider: String, otel: Option, ) -> anyhow::Result> { tokio::fs::create_dir_all(&codex_home).await?; - remove_legacy_state_files(&codex_home).await; + let current_state_name = state_db_filename(); + let current_logs_name = logs_db_filename(); + remove_legacy_db_files( + &codex_home, + current_state_name.as_str(), + STATE_DB_FILENAME, + "state", + ) + .await; + remove_legacy_db_files( + &codex_home, + current_logs_name.as_str(), + LOGS_DB_FILENAME, + "logs", + ) + .await; let state_path = state_db_path(codex_home.as_path()); + let logs_path = logs_db_path(codex_home.as_path()); let existed = tokio::fs::try_exists(&state_path).await.unwrap_or(false); - let pool = match open_sqlite(&state_path).await { + let pool = match open_sqlite(&state_path, &STATE_MIGRATOR).await { Ok(db) => Arc::new(db), Err(err) => { warn!("failed to open state db at {}: {err}", state_path.display()); @@ -92,11 +116,22 @@ impl StateRuntime { return Err(err); } }; + let logs_pool = match open_sqlite(&logs_path, &LOGS_MIGRATOR).await { + Ok(db) => Arc::new(db), + Err(err) => { + warn!("failed to open logs db at {}: {err}", logs_path.display()); + if let Some(otel) = otel.as_ref() { + otel.counter(METRIC_DB_INIT, 1, &[("status", "open_error")]); + } + return Err(err); + } + }; if let Some(otel) = otel.as_ref() { otel.counter(METRIC_DB_INIT, 1, &[("status", "opened")]); } let runtime = Arc::new(Self { pool, + logs_pool, codex_home, default_provider, }); @@ -112,7 +147,7 @@ impl StateRuntime { } } -async fn open_sqlite(path: &Path) -> anyhow::Result { +async fn open_sqlite(path: &Path, migrator: &'static Migrator) -> anyhow::Result { let options = SqliteConnectOptions::new() .filename(path) .create_if_missing(true) @@ -124,26 +159,42 @@ async fn open_sqlite(path: &Path) -> anyhow::Result { .max_connections(5) .connect_with(options) .await?; - MIGRATOR.run(&pool).await?; + migrator.run(&pool).await?; Ok(pool) } +fn db_filename(base_name: &str, version: u32) -> String { + format!("{base_name}_{version}.sqlite") +} + pub fn state_db_filename() -> String { - format!("{STATE_DB_FILENAME}_{STATE_DB_VERSION}.sqlite") + db_filename(STATE_DB_FILENAME, STATE_DB_VERSION) } pub fn state_db_path(codex_home: &Path) -> PathBuf { codex_home.join(state_db_filename()) } -async fn remove_legacy_state_files(codex_home: &Path) { - let current_name = state_db_filename(); +pub fn logs_db_filename() -> String { + db_filename(LOGS_DB_FILENAME, LOGS_DB_VERSION) +} + +pub fn logs_db_path(codex_home: &Path) -> PathBuf { + codex_home.join(logs_db_filename()) +} + +async fn remove_legacy_db_files( + codex_home: &Path, + current_name: &str, + base_name: &str, + db_label: &str, +) { let mut entries = match tokio::fs::read_dir(codex_home).await { Ok(entries) => entries, Err(err) => { warn!( - "failed to read codex_home for state db cleanup {}: {err}", - codex_home.display() + "failed to read codex_home for {db_label} db cleanup {}: {err}", + codex_home.display(), ); return; } @@ -159,37 +210,37 @@ async fn remove_legacy_state_files(codex_home: &Path) { } let file_name = entry.file_name(); let file_name = file_name.to_string_lossy(); - if !should_remove_state_file(file_name.as_ref(), current_name.as_str()) { + if !should_remove_db_file(file_name.as_ref(), current_name, base_name) { continue; } let legacy_path = entry.path(); if let Err(err) = tokio::fs::remove_file(&legacy_path).await { warn!( - "failed to remove legacy state db file {}: {err}", - legacy_path.display() + "failed to remove legacy {db_label} db file {}: {err}", + legacy_path.display(), ); } } } -fn should_remove_state_file(file_name: &str, current_name: &str) -> bool { - let mut base_name = file_name; +fn should_remove_db_file(file_name: &str, current_name: &str, base_name: &str) -> bool { + let mut normalized_name = file_name; for suffix in ["-wal", "-shm", "-journal"] { if let Some(stripped) = file_name.strip_suffix(suffix) { - base_name = stripped; + normalized_name = stripped; break; } } - if base_name == current_name { + if normalized_name == current_name { return false; } - let unversioned_name = format!("{STATE_DB_FILENAME}.sqlite"); - if base_name == unversioned_name { + let unversioned_name = format!("{base_name}.sqlite"); + if normalized_name == unversioned_name { return true; } - let Some(version_with_extension) = base_name.strip_prefix(&format!("{STATE_DB_FILENAME}_")) + let Some(version_with_extension) = normalized_name.strip_prefix(&format!("{base_name}_")) else { return false; }; diff --git a/codex-rs/state/src/runtime/logs.rs b/codex-rs/state/src/runtime/logs.rs index 0a6798f11f..ca6d69e645 100644 --- a/codex-rs/state/src/runtime/logs.rs +++ b/codex-rs/state/src/runtime/logs.rs @@ -11,7 +11,7 @@ impl StateRuntime { return Ok(()); } - let mut tx = self.pool.begin().await?; + let mut tx = self.logs_pool.begin().await?; let mut builder = QueryBuilder::::new( "INSERT INTO logs (ts, ts_nanos, level, target, message, thread_id, process_uuid, module_path, file, line, estimated_bytes) ", ); @@ -76,6 +76,8 @@ impl StateRuntime { over_limit_threads_query.push("estimated_bytes"); over_limit_threads_query.push(") > "); over_limit_threads_query.push_bind(LOG_PARTITION_SIZE_LIMIT_BYTES); + over_limit_threads_query.push(" OR COUNT(*) > "); + over_limit_threads_query.push_bind(LOG_PARTITION_ROW_LIMIT); let over_limit_thread_ids: Vec = over_limit_threads_query .build() .fetch_all(&mut *tx) @@ -103,7 +105,11 @@ WHERE id IN ( ) OVER ( PARTITION BY thread_id ORDER BY ts DESC, ts_nanos DESC, id DESC - ) AS cumulative_bytes + ) AS cumulative_bytes, + ROW_NUMBER() OVER ( + PARTITION BY thread_id + ORDER BY ts DESC, ts_nanos DESC, id DESC + ) AS row_number FROM logs WHERE thread_id IN ( "#, @@ -122,6 +128,8 @@ WHERE id IN ( "#, ); prune_threads.push_bind(LOG_PARTITION_SIZE_LIMIT_BYTES); + prune_threads.push(" OR row_number > "); + prune_threads.push_bind(LOG_PARTITION_ROW_LIMIT); prune_threads.push("\n)"); prune_threads.build().execute(&mut *tx).await?; } @@ -150,6 +158,8 @@ WHERE id IN ( over_limit_processes_query.push("estimated_bytes"); over_limit_processes_query.push(") > "); over_limit_processes_query.push_bind(LOG_PARTITION_SIZE_LIMIT_BYTES); + over_limit_processes_query.push(" OR COUNT(*) > "); + over_limit_processes_query.push_bind(LOG_PARTITION_ROW_LIMIT); let over_limit_process_uuids: Vec = over_limit_processes_query .build() .fetch_all(&mut *tx) @@ -177,7 +187,11 @@ WHERE id IN ( ) OVER ( PARTITION BY process_uuid ORDER BY ts DESC, ts_nanos DESC, id DESC - ) AS cumulative_bytes + ) AS cumulative_bytes, + ROW_NUMBER() OVER ( + PARTITION BY process_uuid + ORDER BY ts DESC, ts_nanos DESC, id DESC + ) AS row_number FROM logs WHERE thread_id IS NULL AND process_uuid IN ( @@ -197,6 +211,8 @@ WHERE id IN ( "#, ); prune_threadless_process_logs.push_bind(LOG_PARTITION_SIZE_LIMIT_BYTES); + prune_threadless_process_logs.push(" OR row_number > "); + prune_threadless_process_logs.push_bind(LOG_PARTITION_ROW_LIMIT); prune_threadless_process_logs.push("\n)"); prune_threadless_process_logs .build() @@ -210,15 +226,16 @@ WHERE id IN ( let mut null_process_usage_query = QueryBuilder::::new("SELECT SUM("); null_process_usage_query.push("estimated_bytes"); null_process_usage_query.push( - ") AS total_bytes FROM logs WHERE thread_id IS NULL AND process_uuid IS NULL", + ") AS total_bytes, COUNT(*) AS row_count FROM logs WHERE thread_id IS NULL AND process_uuid IS NULL", ); - let total_null_process_bytes: Option = null_process_usage_query - .build() - .fetch_one(&mut *tx) - .await? - .try_get("total_bytes")?; + let null_process_usage = null_process_usage_query.build().fetch_one(&mut *tx).await?; + let total_null_process_bytes: Option = + null_process_usage.try_get("total_bytes")?; + let null_process_row_count: i64 = null_process_usage.try_get("row_count")?; - if total_null_process_bytes.unwrap_or(0) > LOG_PARTITION_SIZE_LIMIT_BYTES { + if total_null_process_bytes.unwrap_or(0) > LOG_PARTITION_SIZE_LIMIT_BYTES + || null_process_row_count > LOG_PARTITION_ROW_LIMIT + { let mut prune_threadless_null_process_logs = QueryBuilder::::new( r#" DELETE FROM logs @@ -236,7 +253,11 @@ WHERE id IN ( ) OVER ( PARTITION BY process_uuid ORDER BY ts DESC, ts_nanos DESC, id DESC - ) AS cumulative_bytes + ) AS cumulative_bytes, + ROW_NUMBER() OVER ( + PARTITION BY process_uuid + ORDER BY ts DESC, ts_nanos DESC, id DESC + ) AS row_number FROM logs WHERE thread_id IS NULL AND process_uuid IS NULL @@ -245,6 +266,8 @@ WHERE id IN ( "#, ); prune_threadless_null_process_logs.push_bind(LOG_PARTITION_SIZE_LIMIT_BYTES); + prune_threadless_null_process_logs.push(" OR row_number > "); + prune_threadless_null_process_logs.push_bind(LOG_PARTITION_ROW_LIMIT); prune_threadless_null_process_logs.push("\n)"); prune_threadless_null_process_logs .build() @@ -258,7 +281,7 @@ WHERE id IN ( pub(crate) async fn delete_logs_before(&self, cutoff_ts: i64) -> anyhow::Result { let result = sqlx::query("DELETE FROM logs WHERE ts < ?") .bind(cutoff_ts) - .execute(self.pool.as_ref()) + .execute(self.logs_pool.as_ref()) .await?; Ok(result.rows_affected()) } @@ -280,7 +303,7 @@ WHERE id IN ( let rows = builder .build_query_as::() - .fetch_all(self.pool.as_ref()) + .fetch_all(self.logs_pool.as_ref()) .await?; Ok(rows) } @@ -354,7 +377,7 @@ ORDER BY ts ASC, ts_nanos ASC, id ASC .bind(thread_id) .bind(thread_id) .bind(max_bytes) - .fetch_all(self.pool.as_ref()) + .fetch_all(self.logs_pool.as_ref()) .await?; Ok(lines.concat().into_bytes()) @@ -365,7 +388,7 @@ ORDER BY ts ASC, ts_nanos ASC, id ASC let mut builder = QueryBuilder::::new("SELECT MAX(id) AS max_id FROM logs WHERE 1 = 1"); push_log_filters(&mut builder, query); - let row = builder.build().fetch_one(self.pool.as_ref()).await?; + let row = builder.build().fetch_one(self.logs_pool.as_ref()).await?; let max_id: Option = row.try_get("max_id")?; Ok(max_id.unwrap_or(0)) } @@ -442,7 +465,65 @@ mod tests { use super::test_support::unique_temp_dir; use crate::LogEntry; use crate::LogQuery; + use crate::logs_db_path; + use crate::state_db_path; use pretty_assertions::assert_eq; + use sqlx::SqlitePool; + use sqlx::sqlite::SqliteConnectOptions; + use std::path::Path; + + async fn open_db_pool(path: &Path) -> SqlitePool { + SqlitePool::connect_with( + SqliteConnectOptions::new() + .filename(path) + .create_if_missing(false), + ) + .await + .expect("open sqlite pool") + } + + async fn log_row_count(path: &Path) -> i64 { + let pool = open_db_pool(path).await; + let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM logs") + .fetch_one(&pool) + .await + .expect("count log rows"); + pool.close().await; + count + } + + #[tokio::test] + async fn insert_logs_use_dedicated_log_database() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None) + .await + .expect("initialize runtime"); + + runtime + .insert_logs(&[LogEntry { + ts: 1, + ts_nanos: 0, + level: "INFO".to_string(), + target: "cli".to_string(), + message: Some("dedicated-log-db".to_string()), + thread_id: Some("thread-1".to_string()), + process_uuid: Some("proc-1".to_string()), + module_path: Some("mod".to_string()), + file: Some("main.rs".to_string()), + line: Some(7), + }]) + .await + .expect("insert test logs"); + + let state_count = log_row_count(state_db_path(codex_home.as_path()).as_path()).await; + let logs_count = log_row_count(logs_db_path(codex_home.as_path()).as_path()).await; + + assert_eq!(state_count, 0); + assert_eq!(logs_count, 1); + + let _ = tokio::fs::remove_dir_all(codex_home).await; + } + #[tokio::test] async fn query_logs_with_search_matches_substring() { let codex_home = unique_temp_dir(); @@ -788,6 +869,140 @@ mod tests { let _ = tokio::fs::remove_dir_all(codex_home).await; } + #[tokio::test] + async fn insert_logs_prunes_old_rows_when_thread_exceeds_row_limit() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None) + .await + .expect("initialize runtime"); + + let entries: Vec = (1..=1_001) + .map(|ts| LogEntry { + ts, + ts_nanos: 0, + level: "INFO".to_string(), + target: "cli".to_string(), + message: Some(format!("thread-row-{ts}")), + thread_id: Some("thread-row-limit".to_string()), + process_uuid: Some("proc-1".to_string()), + file: Some("main.rs".to_string()), + line: Some(ts), + module_path: Some("mod".to_string()), + }) + .collect(); + runtime + .insert_logs(&entries) + .await + .expect("insert test logs"); + + let rows = runtime + .query_logs(&LogQuery { + thread_ids: vec!["thread-row-limit".to_string()], + ..Default::default() + }) + .await + .expect("query thread logs"); + + let timestamps: Vec = rows.into_iter().map(|row| row.ts).collect(); + assert_eq!(timestamps.len(), 1_000); + assert_eq!(timestamps.first().copied(), Some(2)); + assert_eq!(timestamps.last().copied(), Some(1_001)); + + let _ = tokio::fs::remove_dir_all(codex_home).await; + } + + #[tokio::test] + async fn insert_logs_prunes_old_threadless_rows_when_process_exceeds_row_limit() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None) + .await + .expect("initialize runtime"); + + let entries: Vec = (1..=1_001) + .map(|ts| LogEntry { + ts, + ts_nanos: 0, + level: "INFO".to_string(), + target: "cli".to_string(), + message: Some(format!("process-row-{ts}")), + thread_id: None, + process_uuid: Some("proc-row-limit".to_string()), + file: Some("main.rs".to_string()), + line: Some(ts), + module_path: Some("mod".to_string()), + }) + .collect(); + runtime + .insert_logs(&entries) + .await + .expect("insert test logs"); + + let rows = runtime + .query_logs(&LogQuery { + include_threadless: true, + ..Default::default() + }) + .await + .expect("query threadless logs"); + + let timestamps: Vec = rows + .into_iter() + .filter(|row| row.process_uuid.as_deref() == Some("proc-row-limit")) + .map(|row| row.ts) + .collect(); + assert_eq!(timestamps.len(), 1_000); + assert_eq!(timestamps.first().copied(), Some(2)); + assert_eq!(timestamps.last().copied(), Some(1_001)); + + let _ = tokio::fs::remove_dir_all(codex_home).await; + } + + #[tokio::test] + async fn insert_logs_prunes_old_threadless_null_process_rows_when_row_limit_exceeded() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None) + .await + .expect("initialize runtime"); + + let entries: Vec = (1..=1_001) + .map(|ts| LogEntry { + ts, + ts_nanos: 0, + level: "INFO".to_string(), + target: "cli".to_string(), + message: Some(format!("null-process-row-{ts}")), + thread_id: None, + process_uuid: None, + file: Some("main.rs".to_string()), + line: Some(ts), + module_path: Some("mod".to_string()), + }) + .collect(); + runtime + .insert_logs(&entries) + .await + .expect("insert test logs"); + + let rows = runtime + .query_logs(&LogQuery { + include_threadless: true, + ..Default::default() + }) + .await + .expect("query threadless logs"); + + let timestamps: Vec = rows + .into_iter() + .filter(|row| row.process_uuid.is_none()) + .map(|row| row.ts) + .collect(); + assert_eq!(timestamps.len(), 1_000); + assert_eq!(timestamps.first().copied(), Some(2)); + assert_eq!(timestamps.last().copied(), Some(1_001)); + + let _ = tokio::fs::remove_dir_all(codex_home).await; + } + #[tokio::test] async fn query_feedback_logs_returns_newest_lines_within_limit_in_order() { let codex_home = unique_temp_dir(); diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index 2a1f44e1e9..e671dfaf62 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -278,6 +278,19 @@ ON CONFLICT(id) DO NOTHING Ok(result.rows_affected() > 0) } + pub async fn touch_thread_updated_at( + &self, + thread_id: ThreadId, + updated_at: DateTime, + ) -> anyhow::Result { + let result = sqlx::query("UPDATE threads SET updated_at = ? WHERE id = ?") + .bind(datetime_to_epoch_seconds(updated_at)) + .bind(thread_id.to_string()) + .execute(self.pool.as_ref()) + .await?; + Ok(result.rows_affected() > 0) + } + pub async fn update_thread_git_info( &self, thread_id: ThreadId, @@ -436,6 +449,7 @@ ON CONFLICT(thread_id, position) DO NOTHING items: &[RolloutItem], otel: Option<&OtelManager>, new_thread_memory_mode: Option<&str>, + updated_at_override: Option>, ) -> anyhow::Result<()> { if items.is_empty() { return Ok(()); @@ -451,7 +465,11 @@ ON CONFLICT(thread_id, position) DO NOTHING if let Some(existing_metadata) = existing_metadata.as_ref() { metadata.prefer_existing_git_info(existing_metadata); } - if let Some(updated_at) = file_modified_time_utc(builder.rollout_path.as_path()).await { + let updated_at = match updated_at_override { + Some(updated_at) => Some(updated_at), + None => file_modified_time_utc(builder.rollout_path.as_path()).await, + }; + if let Some(updated_at) = updated_at { metadata.updated_at = updated_at; } // Keep the thread upsert before dynamic tools to satisfy the foreign key constraint: @@ -649,6 +667,7 @@ mod tests { use super::*; use crate::runtime::test_support::test_thread_metadata; use crate::runtime::test_support::unique_temp_dir; + use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::GitInfo; use codex_protocol::protocol::SessionMeta; use codex_protocol::protocol::SessionMetaLine; @@ -735,7 +754,7 @@ mod tests { })]; runtime - .apply_rollout_items(&builder, &items, None, None) + .apply_rollout_items(&builder, &items, None, None, None) .await .expect("apply_rollout_items should succeed"); @@ -793,7 +812,7 @@ mod tests { })]; runtime - .apply_rollout_items(&builder, &items, None, None) + .apply_rollout_items(&builder, &items, None, None, None) .await .expect("apply_rollout_items should succeed"); @@ -947,4 +966,95 @@ mod tests { assert_eq!(persisted.git_branch, None); assert_eq!(persisted.git_origin_url, None); } + + #[tokio::test] + async fn touch_thread_updated_at_updates_only_updated_at() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None) + .await + .expect("state db should initialize"); + let thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000791").expect("valid thread id"); + let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone()); + metadata.title = "original title".to_string(); + metadata.first_user_message = Some("first-user-message".to_string()); + + runtime + .upsert_thread(&metadata) + .await + .expect("initial upsert should succeed"); + + let touched_at = DateTime::::from_timestamp(1_700_001_111, 0).expect("timestamp"); + let touched = runtime + .touch_thread_updated_at(thread_id, touched_at) + .await + .expect("touch should succeed"); + assert!(touched); + + let persisted = runtime + .get_thread(thread_id) + .await + .expect("thread should load") + .expect("thread should exist"); + assert_eq!(persisted.updated_at, touched_at); + assert_eq!(persisted.title, "original title"); + assert_eq!( + persisted.first_user_message.as_deref(), + Some("first-user-message") + ); + } + + #[tokio::test] + async fn apply_rollout_items_uses_override_updated_at_when_provided() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None) + .await + .expect("state db should initialize"); + let thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000792").expect("valid thread id"); + let metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone()); + + runtime + .upsert_thread(&metadata) + .await + .expect("initial upsert should succeed"); + + let builder = ThreadMetadataBuilder::new( + thread_id, + metadata.rollout_path.clone(), + metadata.created_at, + SessionSource::Cli, + ); + let items = vec![RolloutItem::EventMsg(EventMsg::TokenCount( + codex_protocol::protocol::TokenCountEvent { + info: Some(codex_protocol::protocol::TokenUsageInfo { + total_token_usage: codex_protocol::protocol::TokenUsage { + input_tokens: 0, + cached_input_tokens: 0, + output_tokens: 0, + reasoning_output_tokens: 0, + total_tokens: 321, + }, + last_token_usage: codex_protocol::protocol::TokenUsage::default(), + model_context_window: None, + }), + rate_limits: None, + }, + ))]; + let override_updated_at = + DateTime::::from_timestamp(1_700_001_234, 0).expect("timestamp"); + + runtime + .apply_rollout_items(&builder, &items, None, None, Some(override_updated_at)) + .await + .expect("apply_rollout_items should succeed"); + + let persisted = runtime + .get_thread(thread_id) + .await + .expect("thread should load") + .expect("thread should exist"); + assert_eq!(persisted.tokens_used, 321); + assert_eq!(persisted.updated_at, override_updated_at); + } } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 35fd5229b1..daaab43ac5 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -213,6 +213,7 @@ use crate::tui::FrameRequester; use crate::ui_consts::LIVE_PREFIX_COLS; use codex_chatgpt::connectors; use codex_chatgpt::connectors::AppInfo; +use codex_core::plugins::PluginCapabilitySummary; use codex_core::skills::model::SkillMetadata; use codex_file_search::FileMatch; use std::cell::RefCell; @@ -391,6 +392,7 @@ pub(crate) struct ChatComposer { next_element_id: u64, context_window_used_tokens: Option, skills: Option>, + plugins: Option>, connectors_snapshot: Option, dismissed_mention_popup_token: Option, mention_bindings: HashMap, @@ -510,6 +512,7 @@ impl ChatComposer { next_element_id: 0, context_window_used_tokens: None, skills: None, + plugins: None, connectors_snapshot: None, dismissed_mention_popup_token: None, mention_bindings: HashMap::new(), @@ -546,6 +549,11 @@ impl ChatComposer { self.skills = skills; } + pub fn set_plugin_mentions(&mut self, plugins: Option>) { + self.plugins = plugins; + self.sync_popups(); + } + /// Toggle composer-side image paste handling. /// /// This only affects whether image-like paste content is converted into attachments; the @@ -1926,17 +1934,25 @@ impl ChatComposer { self.skills.as_ref() } + pub fn plugins(&self) -> Option<&Vec> { + self.plugins.as_ref() + } + fn mentions_enabled(&self) -> bool { let skills_ready = self .skills .as_ref() .is_some_and(|skills| !skills.is_empty()); + let plugins_ready = self + .plugins + .as_ref() + .is_some_and(|plugins| !plugins.is_empty()); let connectors_ready = self.connectors_enabled && self .connectors_snapshot .as_ref() .is_some_and(|snapshot| !snapshot.connectors.is_empty()); - skills_ready || connectors_ready + skills_ready || plugins_ready || connectors_ready } /// Extract a token prefixed with `prefix` under the cursor, if any. @@ -3559,6 +3575,58 @@ impl ChatComposer { } } + if let Some(plugins) = self.plugins.as_ref() { + for plugin in plugins { + let (plugin_name, marketplace_name) = plugin + .config_name + .split_once('@') + .unwrap_or((plugin.config_name.as_str(), "")); + let mut capability_labels = Vec::new(); + if plugin.has_skills { + capability_labels.push("skills".to_string()); + } + if !plugin.mcp_server_names.is_empty() { + let mcp_server_count = plugin.mcp_server_names.len(); + capability_labels.push(if mcp_server_count == 1 { + "1 MCP server".to_string() + } else { + format!("{mcp_server_count} MCP servers") + }); + } + if !plugin.app_connector_ids.is_empty() { + let app_count = plugin.app_connector_ids.len(); + capability_labels.push(if app_count == 1 { + "1 app".to_string() + } else { + format!("{app_count} apps") + }); + } + let description = plugin.description.clone().or_else(|| { + Some(if capability_labels.is_empty() { + "Plugin".to_string() + } else { + format!("Plugin · {}", capability_labels.join(" · ")) + }) + }); + let mut search_terms = vec![plugin_name.to_string(), plugin.config_name.clone()]; + if plugin.display_name != plugin_name { + search_terms.push(plugin.display_name.clone()); + } + if !marketplace_name.is_empty() { + search_terms.push(marketplace_name.to_string()); + } + mentions.push(MentionItem { + display_name: plugin.display_name.clone(), + description, + insert_text: format!("${plugin_name}"), + search_terms, + path: Some(format!("plugin://{}", plugin.config_name)), + category_tag: (!marketplace_name.is_empty()) + .then(|| format!("[{marketplace_name}]")), + }); + } + } + if self.connectors_enabled && let Some(snapshot) = self.connectors_snapshot.as_ref() { @@ -5212,6 +5280,59 @@ mod tests { assert_eq!(mention.path, Some("app://connector_1".to_string())); } + #[test] + fn set_plugin_mentions_refreshes_open_mention_popup() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_text_content("$".to_string(), Vec::new(), Vec::new()); + assert!(matches!(composer.active_popup, ActivePopup::None)); + + composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary { + config_name: "sample@test".to_string(), + display_name: "Sample Plugin".to_string(), + description: None, + has_skills: true, + mcp_server_names: vec!["sample".to_string()], + app_connector_ids: Vec::new(), + }])); + + let ActivePopup::Skill(popup) = &composer.active_popup else { + panic!("expected mention popup to open after plugin update"); + }; + let mention = popup + .selected_mention() + .expect("expected plugin mention to be selected"); + assert_eq!(mention.insert_text, "$sample".to_string()); + assert_eq!(mention.path, Some("plugin://sample@test".to_string())); + } + + #[test] + fn plugin_mention_popup_snapshot() { + snapshot_composer_state("plugin_mention_popup", false, |composer| { + composer.set_text_content("$sa".to_string(), Vec::new(), Vec::new()); + composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary { + config_name: "sample@test".to_string(), + display_name: "Sample Plugin".to_string(), + description: Some( + "Plugin that includes the Figma MCP server and Skills for common workflows" + .to_string(), + ), + has_skills: true, + mcp_server_names: vec!["sample".to_string()], + app_connector_ids: vec![codex_core::plugins::AppConnectorId( + "calendar".to_string(), + )], + }])); + }); + } + #[test] fn set_connector_mentions_excludes_disabled_apps_from_mention_popup() { let (tx, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 332bb150f6..24a4c959ef 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -28,6 +28,7 @@ use crate::render::renderable::RenderableItem; use crate::tui::FrameRequester; use bottom_pane_view::BottomPaneView; use codex_core::features::Features; +use codex_core::plugins::PluginCapabilitySummary; use codex_core::skills::model::SkillMetadata; use codex_file_search::FileMatch; use codex_protocol::request_user_input::RequestUserInputEvent; @@ -254,6 +255,11 @@ impl BottomPane { self.request_redraw(); } + pub fn set_plugin_mentions(&mut self, plugins: Option>) { + self.composer.set_plugin_mentions(plugins); + self.request_redraw(); + } + pub fn take_mention_bindings(&mut self) -> Vec { self.composer.take_mention_bindings() } @@ -333,6 +339,10 @@ impl BottomPane { self.composer.skills() } + pub fn plugins(&self) -> Option<&Vec> { + self.composer.plugins() + } + #[cfg(test)] pub(crate) fn context_window_percent(&self) -> Option { self.context_window_percent diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__plugin_mention_popup.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__plugin_mention_popup.snap new file mode 100644 index 0000000000..636cf88817 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__plugin_mention_popup.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› $sa " +" " +" " +" " +" " +" Sample Plugin Plugin that includes the Figma MCP server and Skills for common workflows " +" " +" Press enter to insert or esc to close " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 90b1cab018..9f2295e650 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1225,6 +1225,7 @@ impl ChatWidget { self.refresh_model_display(); self.sync_fast_command_enabled(); self.sync_personality_command_enabled(); + self.refresh_plugin_mentions(); let startup_tooltip_override = self.startup_tooltip_override.take(); let show_fast_status = self.should_show_fast_status(event.service_tier); let session_info_cell = history_cell::new_session_info( @@ -4411,6 +4412,7 @@ impl ChatWidget { .collect(); let mut skill_names_lower: HashSet = HashSet::new(); let mut selected_skill_paths: HashSet = HashSet::new(); + let mut selected_plugin_ids: HashSet = HashSet::new(); if let Some(skills) = self.bottom_pane.skills() { skill_names_lower = skills @@ -4450,6 +4452,30 @@ impl ChatWidget { } } + if let Some(plugins) = self.plugins_for_mentions() { + for binding in &mention_bindings { + let Some(plugin_config_name) = binding + .path + .strip_prefix("plugin://") + .filter(|id| !id.is_empty()) + else { + continue; + }; + if !selected_plugin_ids.insert(plugin_config_name.to_string()) { + continue; + } + if let Some(plugin) = plugins + .iter() + .find(|plugin| plugin.config_name == plugin_config_name) + { + items.push(UserInput::Mention { + name: plugin.display_name.clone(), + path: binding.path.clone(), + }); + } + } + } + let mut selected_app_ids: HashSet = HashSet::new(); if let Some(apps) = self.connectors_for_mentions() { for binding in &mention_bindings { @@ -7153,6 +7179,9 @@ impl ChatWidget { if feature == Feature::Personality { self.sync_personality_command_enabled(); } + if feature == Feature::Plugins { + self.refresh_plugin_mentions(); + } if feature == Feature::PreventIdleSleep { self.turn_sleep_inhibitor = SleepInhibitor::new(enabled); self.turn_sleep_inhibitor @@ -7578,6 +7607,14 @@ impl ChatWidget { } } + fn plugins_for_mentions(&self) -> Option<&[codex_core::plugins::PluginCapabilitySummary]> { + if !self.config.features.enabled(Feature::Plugins) { + return None; + } + + self.bottom_pane.plugins().map(Vec::as_slice) + } + /// Build a placeholder header cell while the session is configuring. fn placeholder_session_header_cell(config: &Config) -> Box { let placeholder_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC); @@ -8077,6 +8114,7 @@ impl ChatWidget { fn on_list_skills(&mut self, ev: ListSkillsResponseEvent) { self.set_skills_from_response(&ev); + self.refresh_plugin_mentions(); } pub(crate) fn on_connectors_loaded( @@ -8160,6 +8198,19 @@ impl ChatWidget { self.bottom_pane.set_connectors_snapshot(Some(snapshot)); } + fn refresh_plugin_mentions(&mut self) { + if !self.config.features.enabled(Feature::Plugins) { + self.bottom_pane.set_plugin_mentions(None); + return; + } + + let plugins = PluginsManager::new(self.config.codex_home.clone()) + .plugins_for_config(&self.config) + .capability_summaries() + .to_vec(); + self.bottom_pane.set_plugin_mentions(Some(plugins)); + } + pub(crate) fn open_review_popup(&mut self) { let mut items: Vec = Vec::new(); diff --git a/codex-rs/tui/src/chatwidget/skills.rs b/codex-rs/tui/src/chatwidget/skills.rs index efca42872b..32dcb1ae88 100644 --- a/codex-rs/tui/src/chatwidget/skills.rs +++ b/codex-rs/tui/src/chatwidget/skills.rs @@ -296,7 +296,13 @@ pub(crate) struct ToolMentions { linked_paths: HashMap, } +const TOOL_MENTION_SIGIL: char = '$'; + fn extract_tool_mentions_from_text(text: &str) -> ToolMentions { + extract_tool_mentions_from_text_with_sigil(text, TOOL_MENTION_SIGIL) +} + +fn extract_tool_mentions_from_text_with_sigil(text: &str, sigil: char) -> ToolMentions { let text_bytes = text.as_bytes(); let mut names: HashSet = HashSet::new(); let mut linked_paths: HashMap = HashMap::new(); @@ -306,10 +312,10 @@ fn extract_tool_mentions_from_text(text: &str) -> ToolMentions { let byte = text_bytes[index]; if byte == b'[' && let Some((name, path, end_index)) = - parse_linked_tool_mention(text, text_bytes, index) + parse_linked_tool_mention(text, text_bytes, index, sigil) { if !is_common_env_var(name) { - if !is_app_or_mcp_path(path) { + if is_skill_path(path) { names.insert(name.to_string()); } linked_paths @@ -320,7 +326,7 @@ fn extract_tool_mentions_from_text(text: &str) -> ToolMentions { continue; } - if byte != b'$' { + if byte != sigil as u8 { index += 1; continue; } @@ -359,13 +365,14 @@ fn parse_linked_tool_mention<'a>( text: &'a str, text_bytes: &[u8], start: usize, + sigil: char, ) -> Option<(&'a str, &'a str, usize)> { - let dollar_index = start + 1; - if text_bytes.get(dollar_index) != Some(&b'$') { + let sigil_index = start + 1; + if text_bytes.get(sigil_index) != Some(&(sigil as u8)) { return None; } - let name_start = dollar_index + 1; + let name_start = sigil_index + 1; let first_name_byte = text_bytes.get(name_start)?; if !is_mention_name_char(*first_name_byte) { return None; @@ -434,7 +441,7 @@ fn is_mention_name_char(byte: u8) -> bool { } fn is_skill_path(path: &str) -> bool { - !is_app_or_mcp_path(path) + !path.starts_with("app://") && !path.starts_with("mcp://") && !path.starts_with("plugin://") } fn normalize_skill_path(path: &str) -> &str { @@ -445,7 +452,3 @@ fn app_id_from_path(path: &str) -> Option<&str> { path.strip_prefix("app://") .filter(|value| !value.is_empty()) } - -fn is_app_or_mcp_path(path: &str) -> bool { - path.starts_with("app://") || path.starts_with("mcp://") -} diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index ce34b598ba..ddcd015c6e 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -4141,6 +4141,73 @@ async fn item_completed_pops_pending_steer_with_local_image_and_text_elements() assert!(stored_remote_image_urls.is_empty()); } +#[tokio::test(flavor = "multi_thread")] +async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + let conversation_id = ThreadId::new(); + let rollout_file = NamedTempFile::new().unwrap(); + let configured = codex_protocol::protocol::SessionConfiguredEvent { + session_id: conversation_id, + forked_from_id: None, + thread_name: None, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: Some(rollout_file.path().to_path_buf()), + }; + chat.handle_codex_event(Event { + id: "initial".into(), + msg: EventMsg::SessionConfigured(configured), + }); + chat.set_feature_enabled(Feature::Plugins, true); + chat.bottom_pane.set_plugin_mentions(Some(vec![ + codex_core::plugins::PluginCapabilitySummary { + config_name: "sample@test".to_string(), + display_name: "Sample Plugin".to_string(), + description: None, + has_skills: true, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + }, + ])); + + chat.submit_user_message(UserMessage { + text: "$sample".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: vec![MentionBinding { + mention: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }], + }); + + let Op::UserTurn { items, .. } = next_submit_op(&mut op_rx) else { + panic!("expected Op::UserTurn"); + }; + assert_eq!( + items, + vec![ + UserInput::Text { + text: "$sample".to_string(), + text_elements: Vec::new(), + }, + UserInput::Mention { + name: "Sample Plugin".to_string(), + path: "plugin://sample@test".to_string(), + }, + ] + ); +} + #[tokio::test] async fn steer_enter_during_final_stream_preserves_follow_up_prompts_in_order() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index 7cab734370..e400c542a1 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -324,7 +324,6 @@ fn format_network_constraints(network: &NetworkConstraints) -> String { let mut parts = Vec::new(); let NetworkConstraints { - enabled, http_port, socks_port, allow_upstream_proxy, @@ -336,9 +335,6 @@ fn format_network_constraints(network: &NetworkConstraints) -> String { allow_local_binding, } = network; - if let Some(enabled) = enabled { - parts.push(format!("enabled={enabled}")); - } if let Some(http_port) = http_port { parts.push(format!("http_port={http_port}")); } @@ -506,7 +502,6 @@ mod tests { ), network: Some(Sourced::new( NetworkConstraints { - enabled: Some(true), allowed_domains: Some(vec!["example.com".to_string()]), ..Default::default() }, @@ -569,7 +564,7 @@ mod tests { assert!(rendered.contains("mcp_servers: docs (source: MDM managed_config.toml (legacy))")); assert!(rendered.contains("enforce_residency: us (source: cloud requirements)")); assert!(rendered.contains( - "experimental_network: enabled=true, allowed_domains=[example.com] (source: cloud requirements)" + "experimental_network: allowed_domains=[example.com] (source: cloud requirements)" )); assert!(!rendered.contains(" - rules:")); } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 3a568a0f8f..1167a391ec 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -2580,6 +2580,8 @@ mod tests { responses_api_engine_service_ttft_ms: 460, responses_api_engine_iapi_tbt_ms: 1_180, responses_api_engine_service_tbt_ms: 1_240, + turn_ttft_ms: 0, + turn_ttfm_ms: 0, }; let cell = FinalMessageSeparator::new(Some(12), Some(summary)); let rendered = render_lines(&cell.display_lines(600)); diff --git a/codex-rs/tui/src/mention_codec.rs b/codex-rs/tui/src/mention_codec.rs index 5a7670e140..a517896a99 100644 --- a/codex-rs/tui/src/mention_codec.rs +++ b/codex-rs/tui/src/mention_codec.rs @@ -7,6 +7,8 @@ pub(crate) struct LinkedMention { pub(crate) path: String, } +const TOOL_MENTION_SIGIL: char = '$'; + #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct DecodedHistoryText { pub(crate) text: String, @@ -31,7 +33,7 @@ pub(crate) fn encode_history_mentions(text: &str, mentions: &[LinkedMention]) -> let mut index = 0usize; while index < bytes.len() { - if bytes[index] == b'$' { + if bytes[index] == TOOL_MENTION_SIGIL as u8 { let name_start = index + 1; if let Some(first) = bytes.get(name_start) && is_mention_name_char(*first) @@ -46,7 +48,7 @@ pub(crate) fn encode_history_mentions(text: &str, mentions: &[LinkedMention]) -> let name = &text[name_start..name_end]; if let Some(path) = mentions_by_name.get_mut(name).and_then(VecDeque::pop_front) { out.push('['); - out.push('$'); + out.push(TOOL_MENTION_SIGIL); out.push_str(name); out.push_str("]("); out.push_str(path); @@ -75,11 +77,12 @@ pub(crate) fn decode_history_mentions(text: &str) -> DecodedHistoryText { while index < bytes.len() { if bytes[index] == b'[' - && let Some((name, path, end_index)) = parse_linked_tool_mention(text, bytes, index) + && let Some((name, path, end_index)) = + parse_linked_tool_mention(text, bytes, index, TOOL_MENTION_SIGIL) && !is_common_env_var(name) && is_tool_path(path) { - out.push('$'); + out.push(TOOL_MENTION_SIGIL); out.push_str(name); mentions.push(LinkedMention { mention: name.to_string(), @@ -106,13 +109,14 @@ fn parse_linked_tool_mention<'a>( text: &'a str, text_bytes: &[u8], start: usize, + sigil: char, ) -> Option<(&'a str, &'a str, usize)> { - let dollar_index = start + 1; - if text_bytes.get(dollar_index) != Some(&b'$') { + let sigil_index = start + 1; + if text_bytes.get(sigil_index) != Some(&(sigil as u8)) { return None; } - let name_start = dollar_index + 1; + let name_start = sigil_index + 1; let first_name_byte = text_bytes.get(name_start)?; if !is_mention_name_char(*first_name_byte) { return None; @@ -183,6 +187,7 @@ fn is_common_env_var(name: &str) -> bool { fn is_tool_path(path: &str) -> bool { path.starts_with("app://") || path.starts_with("mcp://") + || path.starts_with("plugin://") || path.starts_with("skill://") || path .rsplit(['/', '\\']) @@ -198,9 +203,9 @@ mod tests { #[test] fn decode_history_mentions_restores_visible_tokens() { let decoded = decode_history_mentions( - "Use [$figma](app://figma-1) and [$figma](/tmp/figma/SKILL.md).", + "Use [$figma](app://figma-1), [$sample](plugin://sample@test), and [$figma](/tmp/figma/SKILL.md).", ); - assert_eq!(decoded.text, "Use $figma and $figma."); + assert_eq!(decoded.text, "Use $figma, $sample, and $figma."); assert_eq!( decoded.mentions, vec![ @@ -208,6 +213,10 @@ mod tests { mention: "figma".to_string(), path: "app://figma-1".to_string(), }, + LinkedMention { + mention: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }, LinkedMention { mention: "figma".to_string(), path: "/tmp/figma/SKILL.md".to_string(), @@ -218,7 +227,7 @@ mod tests { #[test] fn encode_history_mentions_links_bound_mentions_in_order() { - let text = "$figma then $figma then $other"; + let text = "$figma then $sample then $figma then $other"; let encoded = encode_history_mentions( text, &[ @@ -226,6 +235,10 @@ mod tests { mention: "figma".to_string(), path: "app://figma-app".to_string(), }, + LinkedMention { + mention: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }, LinkedMention { mention: "figma".to_string(), path: "/tmp/figma/SKILL.md".to_string(), @@ -234,7 +247,7 @@ mod tests { ); assert_eq!( encoded, - "[$figma](app://figma-app) then [$figma](/tmp/figma/SKILL.md) then $other" + "[$figma](app://figma-app) then [$sample](plugin://sample@test) then [$figma](/tmp/figma/SKILL.md) then $other" ); } }