diff --git a/README.md b/README.md index 722048b30b..4678abd800 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ Then simply run `codex` to get started: codex ``` +If you're running into upgrade issues with Homebrew, see the [FAQ entry on brew upgrade codex](./docs/faq.md#brew-update-codex-isnt-upgrading-me). +
You can also go to the latest GitHub Release and download the appropriate binary for your platform. diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 97144613d6..880efbbf4f 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -843,6 +843,7 @@ dependencies = [ "codex-backend-client", "codex-common", "codex-core", + "codex-feedback", "codex-file-search", "codex-login", "codex-protocol", @@ -853,6 +854,7 @@ dependencies = [ "pretty_assertions", "serde", "serde_json", + "serial_test", "tempfile", "tokio", "toml", @@ -1061,11 +1063,14 @@ dependencies = [ "codex-apply-patch", "codex-async-utils", "codex-file-search", + "codex-git-tooling", "codex-otel", "codex-protocol", "codex-rmcp-client", "codex-utils-pty", + "codex-utils-readiness", "codex-utils-string", + "codex-utils-tokenizer", "core-foundation 0.9.4", "core_test_support", "dirs", @@ -1074,6 +1079,7 @@ dependencies = [ "escargot", "eventsource-stream", "futures", + "http", "indexmap 2.10.0", "landlock", "libc", @@ -1206,8 +1212,11 @@ version = "0.0.0" dependencies = [ "assert_matches", "pretty_assertions", + "schemars 0.8.22", + "serde", "tempfile", "thiserror 2.0.16", + "ts-rs", "walkdir", ] @@ -1325,6 +1334,7 @@ version = "0.0.0" dependencies = [ "anyhow", "base64", + "codex-git-tooling", "icu_decimal", "icu_locale_core", "mcp-types", @@ -1425,7 +1435,6 @@ dependencies = [ "codex-core", "codex-feedback", "codex-file-search", - "codex-git-tooling", "codex-login", "codex-ollama", "codex-protocol", @@ -1633,6 +1642,7 @@ dependencies = [ "anyhow", "assert_cmd", "codex-core", + "codex-protocol", "notify", "regex-lite", "serde_json", @@ -4952,9 +4962,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e35d31f89beb59c83bc31363426da25b323ce0c2e5b53c7bf29867d16ee7898" +checksum = "1fdad1258f7259fdc0f2dfc266939c82c3b5d1fd72bcde274d600cdc27e60243" dependencies = [ "base64", "bytes", @@ -4986,9 +4996,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d88518b38110c439a03f0f4eee40e5105d648a530711cb87f98991e3f324a664" +checksum = "ede0589a208cc7ce81d1be68aa7e74b917fcd03c81528408bab0457e187dcd9b" dependencies = [ "darling 0.21.3", "proc-macro2", @@ -5454,9 +5464,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", @@ -5464,18 +5474,18 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index d72c527bc2..46a46e23e8 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -116,6 +116,7 @@ env_logger = "0.11.5" escargot = "0.5" eventsource-stream = "0.2.3" futures = { version = "0.3", default-features = false } +http = "1.3.1" icu_decimal = "2.0.0" icu_locale_core = "2.0.0" ignore = "0.4.23" @@ -153,7 +154,7 @@ ratatui = "0.29.0" ratatui-macros = "0.6.0" regex-lite = "0.1.7" reqwest = "0.12" -rmcp = { version = "0.8.2", default-features = false } +rmcp = { version = "0.8.3", default-features = false } schemars = "0.8.22" seccompiler = "0.5.0" sentry = "0.34.0" diff --git a/codex-rs/app-server-protocol/src/protocol.rs b/codex-rs/app-server-protocol/src/protocol.rs index 4b4563aadc..74e9b6f9e2 100644 --- a/codex-rs/app-server-protocol/src/protocol.rs +++ b/codex-rs/app-server-protocol/src/protocol.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::FileChange; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::SandboxCommandAssessment; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::TurnAbortReason; use paste::paste; @@ -123,11 +124,18 @@ client_request_definitions! { response: GetAccountRateLimitsResponse, }, + #[serde(rename = "feedback/upload")] + #[ts(rename = "feedback/upload")] + UploadFeedback { + params: UploadFeedbackParams, + response: UploadFeedbackResponse, + }, + #[serde(rename = "account/read")] #[ts(rename = "account/read")] GetAccount { params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, - response: Option, + response: GetAccountResponse, }, /// DEPRECATED APIs below @@ -224,6 +232,12 @@ client_request_definitions! { }, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct GetAccountResponse { + pub account: Account, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct InitializeParams { @@ -252,6 +266,10 @@ pub struct NewConversationParams { #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, + /// Override the model provider to use for this session. + #[serde(skip_serializing_if = "Option::is_none")] + pub model_provider: Option, + /// Configuration profile from config.toml to specify default options. #[serde(skip_serializing_if = "Option::is_none")] pub profile: Option, @@ -313,6 +331,12 @@ pub struct ListConversationsParams { /// Opaque pagination cursor returned by a previous call. #[serde(skip_serializing_if = "Option::is_none")] pub cursor: Option, + /// Optional model provider filter (matches against session metadata). + /// - None => filter by the server's default model provider + /// - Some([]) => no filtering, include all providers + /// - Some([...]) => only include sessions with one of the specified providers + #[serde(skip_serializing_if = "Option::is_none")] + pub model_providers: Option>, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -324,6 +348,8 @@ pub struct ConversationSummary { /// RFC3339 timestamp string for the session start, if available. #[serde(skip_serializing_if = "Option::is_none")] pub timestamp: Option, + /// Model provider recorded for the session (resolved when absent in metadata). + pub model_provider: String, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -377,6 +403,23 @@ pub struct ListModelsResponse { pub next_cursor: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct UploadFeedbackParams { + pub classification: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, + pub include_logs: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct UploadFeedbackResponse { + pub thread_id: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(tag = "type")] #[ts(tag = "type")] @@ -710,6 +753,8 @@ pub struct SendUserMessageResponse {} #[serde(rename_all = "camelCase")] pub struct AddConversationListenerParams { pub conversation_id: ConversationId, + #[serde(default)] + pub experimental_raw_events: bool, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -841,6 +886,8 @@ pub struct ExecCommandApprovalParams { pub cwd: PathBuf, #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub risk: Option, pub parsed_cmd: Vec, } @@ -989,6 +1036,7 @@ mod tests { request_id: RequestId::Integer(42), params: NewConversationParams { model: Some("gpt-5-codex".to_string()), + model_provider: None, profile: None, cwd: None, approval_policy: Some(AskForApproval::OnRequest), @@ -1057,6 +1105,7 @@ mod tests { command: vec!["echo".to_string(), "hello".to_string()], cwd: PathBuf::from("/tmp"), reason: Some("because tests".to_string()), + risk: None, parsed_cmd: vec![ParsedCommand::Unknown { cmd: "echo hello".to_string(), }], diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 5efbefd6b0..d693e7bb7f 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -24,6 +24,7 @@ codex-file-search = { workspace = true } codex-login = { workspace = true } codex-protocol = { workspace = true } codex-app-server-protocol = { workspace = true } +codex-feedback = { workspace = true } codex-utils-json-to-toml = { workspace = true } chrono = { workspace = true } serde = { workspace = true, features = ["derive"] } @@ -47,6 +48,7 @@ base64 = { workspace = true } core_test_support = { workspace = true } os_info = { workspace = true } pretty_assertions = { workspace = true } +serial_test = { workspace = true } tempfile = { workspace = true } toml = { workspace = true } wiremock = { workspace = true } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 5c10a89c38..9b66322047 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -52,6 +52,8 @@ use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::SessionConfiguredNotification; use codex_app_server_protocol::SetDefaultModelParams; use codex_app_server_protocol::SetDefaultModelResponse; +use codex_app_server_protocol::UploadFeedbackParams; +use codex_app_server_protocol::UploadFeedbackResponse; use codex_app_server_protocol::UserInfoResponse; use codex_app_server_protocol::UserSavedConfig; use codex_backend_client::Client as BackendClient; @@ -85,6 +87,7 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::ExecApprovalRequestEvent; use codex_core::protocol::Op; use codex_core::protocol::ReviewDecision; +use codex_feedback::CodexFeedback; use codex_login::ServerOptions as LoginServerOptions; use codex_login::ShutdownHandle; use codex_login::run_login_server; @@ -136,6 +139,7 @@ pub(crate) struct CodexMessageProcessor { // Queue of pending interrupt requests per conversation. We reply when TurnAborted arrives. pending_interrupts: Arc>>>, pending_fuzzy_searches: Arc>>>, + feedback: CodexFeedback, } impl CodexMessageProcessor { @@ -145,6 +149,7 @@ impl CodexMessageProcessor { outgoing: Arc, codex_linux_sandbox_exe: Option, config: Arc, + feedback: CodexFeedback, ) -> Self { Self { auth_manager, @@ -156,6 +161,7 @@ impl CodexMessageProcessor { active_login: Arc::new(Mutex::new(None)), pending_interrupts: Arc::new(Mutex::new(HashMap::new())), pending_fuzzy_searches: Arc::new(Mutex::new(HashMap::new())), + feedback, } } @@ -275,6 +281,9 @@ impl CodexMessageProcessor { } => { self.get_account_rate_limits(request_id).await; } + ClientRequest::UploadFeedback { request_id, params } => { + self.upload_feedback(request_id, params).await; + } } } @@ -818,19 +827,38 @@ impl CodexMessageProcessor { request_id: RequestId, params: ListConversationsParams, ) { - let page_size = params.page_size.unwrap_or(25); + let ListConversationsParams { + page_size, + cursor, + model_providers: model_provider, + } = params; + let page_size = page_size.unwrap_or(25); // Decode the optional cursor string to a Cursor via serde (Cursor implements Deserialize from string) - let cursor_obj: Option = match params.cursor { + let cursor_obj: Option = match cursor { Some(s) => serde_json::from_str::(&format!("\"{s}\"")).ok(), None => None, }; let cursor_ref = cursor_obj.as_ref(); + let model_provider_filter = match model_provider { + Some(providers) => { + if providers.is_empty() { + None + } else { + Some(providers) + } + } + None => Some(vec![self.config.model_provider_id.clone()]), + }; + let model_provider_slice = model_provider_filter.as_deref(); + let fallback_provider = self.config.model_provider_id.clone(); let page = match RolloutRecorder::list_conversations( &self.config.codex_home, page_size, cursor_ref, INTERACTIVE_SESSION_SOURCES, + model_provider_slice, + fallback_provider.as_str(), ) .await { @@ -849,7 +877,7 @@ impl CodexMessageProcessor { let items = page .items .into_iter() - .filter_map(|it| extract_conversation_summary(it.path, &it.head)) + .filter_map(|it| extract_conversation_summary(it.path, &it.head, &fallback_provider)) .collect(); // Encode next_cursor as a plain string @@ -1256,7 +1284,10 @@ impl CodexMessageProcessor { request_id: RequestId, params: AddConversationListenerParams, ) { - let AddConversationListenerParams { conversation_id } = params; + let AddConversationListenerParams { + conversation_id, + experimental_raw_events, + } = params; let Ok(conversation) = self .conversation_manager .get_conversation(conversation_id) @@ -1293,6 +1324,11 @@ impl CodexMessageProcessor { } }; + if let EventMsg::RawResponseItem(_) = &event.msg + && !experimental_raw_events { + continue; + } + // For now, we send a notification for every event, // JSON-serializing the `Event` as-is, but these should // be migrated to be variants of `ServerNotification` @@ -1410,6 +1446,77 @@ impl CodexMessageProcessor { let response = FuzzyFileSearchResponse { files: results }; self.outgoing.send_response(request_id, response).await; } + + async fn upload_feedback(&self, request_id: RequestId, params: UploadFeedbackParams) { + let UploadFeedbackParams { + classification, + reason, + conversation_id, + include_logs, + } = params; + + let snapshot = self.feedback.snapshot(conversation_id); + let thread_id = snapshot.thread_id.clone(); + + let validated_rollout_path = if include_logs { + match conversation_id { + Some(conv_id) => self.resolve_rollout_path(conv_id).await, + None => None, + } + } else { + None + }; + + let upload_result = tokio::task::spawn_blocking(move || { + let rollout_path_ref = validated_rollout_path.as_deref(); + snapshot.upload_feedback( + &classification, + reason.as_deref(), + include_logs, + rollout_path_ref, + ) + }) + .await; + + let upload_result = match upload_result { + Ok(result) => result, + Err(join_err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to upload feedback: {join_err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + match upload_result { + Ok(()) => { + let response = UploadFeedbackResponse { thread_id }; + self.outgoing.send_response(request_id, response).await; + } + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to upload feedback: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + } + } + } + + async fn resolve_rollout_path(&self, conversation_id: ConversationId) -> Option { + match self + .conversation_manager + .get_conversation(conversation_id) + .await + { + Ok(conv) => Some(conv.rollout_path()), + Err(_) => None, + } + } } async fn apply_bespoke_event_handling( @@ -1447,6 +1554,7 @@ async fn apply_bespoke_event_handling( command, cwd, reason, + risk, parsed_cmd, }) => { let params = ExecCommandApprovalParams { @@ -1455,6 +1563,7 @@ async fn apply_bespoke_event_handling( command, cwd, reason, + risk, parsed_cmd, }; let rx = outgoing @@ -1501,6 +1610,7 @@ async fn derive_config_from_params( ) -> std::io::Result { let NewConversationParams { model, + model_provider, profile, cwd, approval_policy, @@ -1516,13 +1626,14 @@ async fn derive_config_from_params( cwd: cwd.map(PathBuf::from), approval_policy, sandbox_mode, - model_provider: None, + model_provider, codex_linux_sandbox_exe, base_instructions, include_apply_patch_tool, include_view_image_tool: None, show_raw_agent_reasoning: None, tools_web_search_request: None, + experimental_sandbox_command_assessment: None, additional_writable_roots: Vec::new(), }; @@ -1616,6 +1727,7 @@ async fn on_exec_approval_response( fn extract_conversation_summary( path: PathBuf, head: &[serde_json::Value], + fallback_provider: &str, ) -> Option { let session_meta = match head.first() { Some(first_line) => serde_json::from_value::(first_line.clone()).ok()?, @@ -1640,12 +1752,17 @@ fn extract_conversation_summary( } else { Some(session_meta.timestamp.clone()) }; + let conversation_id = session_meta.id; + let model_provider = session_meta + .model_provider + .unwrap_or_else(|| fallback_provider.to_string()); Some(ConversationSummary { - conversation_id: session_meta.id, + conversation_id, timestamp, path, preview: preview.to_string(), + model_provider, }) } @@ -1669,7 +1786,8 @@ mod tests { "cwd": "/", "originator": "codex", "cli_version": "0.0.0", - "instructions": null + "instructions": null, + "model_provider": "test-provider" }), json!({ "type": "message", @@ -1689,7 +1807,8 @@ mod tests { }), ]; - let summary = extract_conversation_summary(path.clone(), &head).expect("summary"); + let summary = + extract_conversation_summary(path.clone(), &head, "test-provider").expect("summary"); assert_eq!(summary.conversation_id, conversation_id); assert_eq!( @@ -1698,6 +1817,7 @@ mod tests { ); assert_eq!(summary.path, path); assert_eq!(summary.preview, "Count to 5"); + assert_eq!(summary.model_provider, "test-provider"); Ok(()) } } diff --git a/codex-rs/app-server/src/fuzzy_file_search.rs b/codex-rs/app-server/src/fuzzy_file_search.rs index 6c83a0f4ec..fcb05852ba 100644 --- a/codex-rs/app-server/src/fuzzy_file_search.rs +++ b/codex-rs/app-server/src/fuzzy_file_search.rs @@ -46,6 +46,7 @@ pub(crate) async fn run_fuzzy_file_search( threads, cancel_flag, COMPUTE_INDICES, + true, ) { Ok(res) => Ok((root, res)), Err(err) => Err((root, err)), diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index c4102f8262..6ef986919f 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -12,16 +12,19 @@ use crate::message_processor::MessageProcessor; use crate::outgoing_message::OutgoingMessage; use crate::outgoing_message::OutgoingMessageSender; use codex_app_server_protocol::JSONRPCMessage; +use codex_feedback::CodexFeedback; use tokio::io::AsyncBufReadExt; use tokio::io::AsyncWriteExt; use tokio::io::BufReader; use tokio::io::{self}; use tokio::sync::mpsc; +use tracing::Level; use tracing::debug; use tracing::error; use tracing::info; use tracing_subscriber::EnvFilter; use tracing_subscriber::Layer; +use tracing_subscriber::filter::Targets; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; @@ -82,6 +85,8 @@ pub async fn run_main( std::io::Error::new(ErrorKind::InvalidData, format!("error loading config: {e}")) })?; + let feedback = CodexFeedback::new(); + let otel = codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION")).map_err(|e| { std::io::Error::new( @@ -96,8 +101,15 @@ pub async fn run_main( .with_writer(std::io::stderr) .with_filter(EnvFilter::from_default_env()); + let feedback_layer = tracing_subscriber::fmt::layer() + .with_writer(feedback.make_writer()) + .with_ansi(false) + .with_target(false) + .with_filter(Targets::new().with_default(Level::TRACE)); + let _ = tracing_subscriber::registry() .with(stderr_fmt) + .with(feedback_layer) .with(otel.as_ref().map(|provider| { OpenTelemetryTracingBridge::new(&provider.logger).with_filter( tracing_subscriber::filter::filter_fn(codex_core::otel_init::codex_export_filter), @@ -112,6 +124,7 @@ pub async fn run_main( outgoing_message_sender, codex_linux_sandbox_exe, std::sync::Arc::new(config), + feedback.clone(), ); async move { while let Some(msg) = incoming_rx.recv().await { diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 15086c19e1..a2c192cf2a 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -17,6 +17,7 @@ use codex_core::ConversationManager; use codex_core::config::Config; use codex_core::default_client::USER_AGENT_SUFFIX; use codex_core::default_client::get_codex_user_agent; +use codex_feedback::CodexFeedback; use codex_protocol::protocol::SessionSource; use std::sync::Arc; @@ -33,6 +34,7 @@ impl MessageProcessor { outgoing: OutgoingMessageSender, codex_linux_sandbox_exe: Option, config: Arc, + feedback: CodexFeedback, ) -> Self { let outgoing = Arc::new(outgoing); let auth_manager = AuthManager::shared(config.codex_home.clone(), false); @@ -46,6 +48,7 @@ impl MessageProcessor { outgoing.clone(), codex_linux_sandbox_exe, config, + feedback, ); Self { diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index f6d35b8d96..90c7645d58 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -30,6 +30,7 @@ use codex_app_server_protocol::SendUserMessageParams; use codex_app_server_protocol::SendUserTurnParams; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::SetDefaultModelParams; +use codex_app_server_protocol::UploadFeedbackParams; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCMessage; @@ -242,6 +243,15 @@ impl McpProcess { self.send_request("account/rateLimits/read", None).await } + /// Send a `feedback/upload` JSON-RPC request. + pub async fn send_upload_feedback_request( + &mut self, + params: UploadFeedbackParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("feedback/upload", params).await + } + /// Send a `userInfo` JSON-RPC request. pub async fn send_user_info_request(&mut self) -> anyhow::Result { self.send_request("userInfo", None).await diff --git a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs index 30b90f6e4a..5c010bf034 100644 --- a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs +++ b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs @@ -103,7 +103,10 @@ async fn test_codex_jsonrpc_conversation_flow() { // 2) addConversationListener let add_listener_id = mcp - .send_add_conversation_listener_request(AddConversationListenerParams { conversation_id }) + .send_add_conversation_listener_request(AddConversationListenerParams { + conversation_id, + experimental_raw_events: false, + }) .await .expect("send addConversationListener"); let add_listener_resp: JSONRPCResponse = timeout( @@ -252,7 +255,10 @@ async fn test_send_user_turn_changes_approval_policy_behavior() { // 2) addConversationListener let add_listener_id = mcp - .send_add_conversation_listener_request(AddConversationListenerParams { conversation_id }) + .send_add_conversation_listener_request(AddConversationListenerParams { + conversation_id, + experimental_raw_events: false, + }) .await .expect("send addConversationListener"); let _: AddConversationSubscriptionResponse = @@ -311,6 +317,7 @@ async fn test_send_user_turn_changes_approval_policy_behavior() { ], cwd: working_directory.clone(), reason: None, + risk: None, parsed_cmd: vec![ParsedCommand::Unknown { cmd: "python3 -c 'print(42)'".to_string() }], @@ -458,7 +465,10 @@ async fn test_send_user_turn_updates_sandbox_and_cwd_between_turns() { .expect("deserialize newConversation response"); let add_listener_id = mcp - .send_add_conversation_listener_request(AddConversationListenerParams { conversation_id }) + .send_add_conversation_listener_request(AddConversationListenerParams { + conversation_id, + experimental_raw_events: false, + }) .await .expect("send addConversationListener"); timeout( diff --git a/codex-rs/app-server/tests/suite/create_conversation.rs b/codex-rs/app-server/tests/suite/create_conversation.rs index 37a0db84fa..f507baa30d 100644 --- a/codex-rs/app-server/tests/suite/create_conversation.rs +++ b/codex-rs/app-server/tests/suite/create_conversation.rs @@ -67,7 +67,10 @@ async fn test_conversation_create_and_send_message_ok() { // Add a listener so we receive notifications for this conversation (not strictly required for this test). let add_listener_id = mcp - .send_add_conversation_listener_request(AddConversationListenerParams { conversation_id }) + .send_add_conversation_listener_request(AddConversationListenerParams { + conversation_id, + experimental_raw_events: false, + }) .await .expect("send addConversationListener"); let _sub: AddConversationSubscriptionResponse = diff --git a/codex-rs/app-server/tests/suite/interrupt.rs b/codex-rs/app-server/tests/suite/interrupt.rs index 2500d20ff4..1c4c05e368 100644 --- a/codex-rs/app-server/tests/suite/interrupt.rs +++ b/codex-rs/app-server/tests/suite/interrupt.rs @@ -88,7 +88,10 @@ async fn shell_command_interruption() -> anyhow::Result<()> { // 2) addConversationListener let add_listener_id = mcp - .send_add_conversation_listener_request(AddConversationListenerParams { conversation_id }) + .send_add_conversation_listener_request(AddConversationListenerParams { + conversation_id, + experimental_raw_events: false, + }) .await?; let _add_listener_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, diff --git a/codex-rs/app-server/tests/suite/list_resume.rs b/codex-rs/app-server/tests/suite/list_resume.rs index a4178b0873..85416f60bd 100644 --- a/codex-rs/app-server/tests/suite/list_resume.rs +++ b/codex-rs/app-server/tests/suite/list_resume.rs @@ -30,18 +30,21 @@ async fn test_list_and_resume_conversations() { "2025-01-02T12-00-00", "2025-01-02T12:00:00Z", "Hello A", + Some("openai"), ); create_fake_rollout( codex_home.path(), "2025-01-01T13-00-00", "2025-01-01T13:00:00Z", "Hello B", + Some("openai"), ); create_fake_rollout( codex_home.path(), "2025-01-01T12-00-00", "2025-01-01T12:00:00Z", "Hello C", + None, ); let mut mcp = McpProcess::new(codex_home.path()) @@ -57,6 +60,7 @@ async fn test_list_and_resume_conversations() { .send_list_conversations_request(ListConversationsParams { page_size: Some(2), cursor: None, + model_providers: None, }) .await .expect("send listConversations"); @@ -74,6 +78,8 @@ async fn test_list_and_resume_conversations() { // Newest first; preview text should match assert_eq!(items[0].preview, "Hello A"); assert_eq!(items[1].preview, "Hello B"); + assert_eq!(items[0].model_provider, "openai"); + assert_eq!(items[1].model_provider, "openai"); assert!(items[0].path.is_absolute()); assert!(next_cursor.is_some()); @@ -82,6 +88,7 @@ async fn test_list_and_resume_conversations() { .send_list_conversations_request(ListConversationsParams { page_size: Some(2), cursor: next_cursor, + model_providers: None, }) .await .expect("send listConversations page 2"); @@ -99,7 +106,88 @@ async fn test_list_and_resume_conversations() { } = to_response::(resp2).expect("deserialize response"); assert_eq!(items2.len(), 1); assert_eq!(items2[0].preview, "Hello C"); - assert!(next2.is_some()); + assert_eq!(items2[0].model_provider, "openai"); + assert_eq!(next2, None); + + // Add a conversation with an explicit non-OpenAI provider for filter tests. + create_fake_rollout( + codex_home.path(), + "2025-01-01T11-30-00", + "2025-01-01T11:30:00Z", + "Hello TP", + Some("test-provider"), + ); + + // Filtering by model provider should return only matching sessions. + let filter_req_id = mcp + .send_list_conversations_request(ListConversationsParams { + page_size: Some(10), + cursor: None, + model_providers: Some(vec!["test-provider".to_string()]), + }) + .await + .expect("send listConversations filtered"); + let filter_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(filter_req_id)), + ) + .await + .expect("listConversations filtered timeout") + .expect("listConversations filtered resp"); + let ListConversationsResponse { + items: filtered_items, + next_cursor: filtered_next, + } = to_response::(filter_resp).expect("deserialize filtered"); + assert_eq!(filtered_items.len(), 1); + assert_eq!(filtered_next, None); + assert_eq!(filtered_items[0].preview, "Hello TP"); + assert_eq!(filtered_items[0].model_provider, "test-provider"); + + // Empty filter should include every session regardless of provider metadata. + let unfiltered_req_id = mcp + .send_list_conversations_request(ListConversationsParams { + page_size: Some(10), + cursor: None, + model_providers: Some(Vec::new()), + }) + .await + .expect("send listConversations unfiltered"); + let unfiltered_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(unfiltered_req_id)), + ) + .await + .expect("listConversations unfiltered timeout") + .expect("listConversations unfiltered resp"); + let ListConversationsResponse { + items: unfiltered_items, + next_cursor: unfiltered_next, + } = to_response::(unfiltered_resp) + .expect("deserialize unfiltered response"); + assert_eq!(unfiltered_items.len(), 4); + assert!(unfiltered_next.is_none()); + + let empty_req_id = mcp + .send_list_conversations_request(ListConversationsParams { + page_size: Some(10), + cursor: None, + model_providers: Some(vec!["other".to_string()]), + }) + .await + .expect("send listConversations filtered empty"); + let empty_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(empty_req_id)), + ) + .await + .expect("listConversations filtered empty timeout") + .expect("listConversations filtered empty resp"); + let ListConversationsResponse { + items: empty_items, + next_cursor: empty_next, + } = to_response::(empty_resp).expect("deserialize filtered empty"); + assert!(empty_items.is_empty()); + assert!(empty_next.is_none()); // Now resume one of the sessions and expect a SessionConfigured notification and response. let resume_req_id = mcp @@ -152,7 +240,13 @@ async fn test_list_and_resume_conversations() { assert!(!conversation_id.to_string().is_empty()); } -fn create_fake_rollout(codex_home: &Path, filename_ts: &str, meta_rfc3339: &str, preview: &str) { +fn create_fake_rollout( + codex_home: &Path, + filename_ts: &str, + meta_rfc3339: &str, + preview: &str, + model_provider: Option<&str>, +) { let uuid = Uuid::new_v4(); // sessions/YYYY/MM/DD/ derived from filename_ts (YYYY-MM-DDThh-mm-ss) let year = &filename_ts[0..4]; @@ -164,18 +258,22 @@ fn create_fake_rollout(codex_home: &Path, filename_ts: &str, meta_rfc3339: &str, let file_path = dir.join(format!("rollout-{filename_ts}-{uuid}.jsonl")); let mut lines = Vec::new(); // Meta line with timestamp (flattened meta in payload for new schema) + let mut payload = json!({ + "id": uuid, + "timestamp": meta_rfc3339, + "cwd": "/", + "originator": "codex", + "cli_version": "0.0.0", + "instructions": null, + }); + if let Some(provider) = model_provider { + payload["model_provider"] = json!(provider); + } lines.push( json!({ "timestamp": meta_rfc3339, "type": "session_meta", - "payload": { - "id": uuid, - "timestamp": meta_rfc3339, - "cwd": "/", - "originator": "codex", - "cli_version": "0.0.0", - "instructions": null - } + "payload": payload }) .to_string(), ); diff --git a/codex-rs/app-server/tests/suite/login.rs b/codex-rs/app-server/tests/suite/login.rs index 220769b7d6..d4d6374bc4 100644 --- a/codex-rs/app-server/tests/suite/login.rs +++ b/codex-rs/app-server/tests/suite/login.rs @@ -13,6 +13,7 @@ use codex_app_server_protocol::LoginChatGptResponse; use codex_app_server_protocol::LogoutChatGptResponse; use codex_app_server_protocol::RequestId; use codex_login::login_with_api_key; +use serial_test::serial; use tempfile::TempDir; use tokio::time::timeout; @@ -94,6 +95,8 @@ async fn logout_chatgpt_removes_auth() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] +// Serialize tests that launch the login server since it binds to a fixed port. +#[serial(login_port)] async fn login_and_cancel_chatgpt() { let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}")); create_config_toml(codex_home.path()).unwrap_or_else(|err| panic!("write config.toml: {err}")); @@ -208,6 +211,8 @@ async fn login_chatgpt_rejected_when_forced_api() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] +// Serialize tests that launch the login server since it binds to a fixed port. +#[serial(login_port)] async fn login_chatgpt_includes_forced_workspace_query_param() { let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}")); create_config_toml_forced_workspace(codex_home.path(), "ws-forced") diff --git a/codex-rs/app-server/tests/suite/send_message.rs b/codex-rs/app-server/tests/suite/send_message.rs index 22fb02dca9..b3f04c3393 100644 --- a/codex-rs/app-server/tests/suite/send_message.rs +++ b/codex-rs/app-server/tests/suite/send_message.rs @@ -15,6 +15,8 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SendUserMessageParams; use codex_app_server_protocol::SendUserMessageResponse; use codex_protocol::ConversationId; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::time::timeout; @@ -62,7 +64,10 @@ async fn test_send_message_success() { // 2) addConversationListener let add_listener_id = mcp - .send_add_conversation_listener_request(AddConversationListenerParams { conversation_id }) + .send_add_conversation_listener_request(AddConversationListenerParams { + conversation_id, + experimental_raw_events: false, + }) .await .expect("send addConversationListener"); let add_listener_resp: JSONRPCResponse = timeout( @@ -124,6 +129,105 @@ async fn send_message(message: &str, conversation_id: ConversationId, mcp: &mut .expect("should have conversationId"), &serde_json::Value::String(conversation_id.to_string()) ); + + let raw_attempt = tokio::time::timeout( + std::time::Duration::from_millis(200), + mcp.read_stream_until_notification_message("codex/event/raw_response_item"), + ) + .await; + assert!( + raw_attempt.is_err(), + "unexpected raw item notification when not opted in" + ); +} + +#[tokio::test] +async fn test_send_message_raw_notifications_opt_in() { + let responses = vec![ + create_final_assistant_message_sse_response("Done").expect("build mock assistant message"), + ]; + let server = create_mock_chat_completions_server(responses).await; + + let codex_home = TempDir::new().expect("create temp dir"); + create_config_toml(codex_home.path(), &server.uri()).expect("write config.toml"); + + let mut mcp = McpProcess::new(codex_home.path()) + .await + .expect("spawn mcp process"); + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()) + .await + .expect("init timed out") + .expect("init failed"); + + let new_conv_id = mcp + .send_new_conversation_request(NewConversationParams::default()) + .await + .expect("send newConversation"); + let new_conv_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)), + ) + .await + .expect("newConversation timeout") + .expect("newConversation resp"); + let NewConversationResponse { + conversation_id, .. + } = to_response::<_>(new_conv_resp).expect("deserialize newConversation response"); + + let add_listener_id = mcp + .send_add_conversation_listener_request(AddConversationListenerParams { + conversation_id, + experimental_raw_events: true, + }) + .await + .expect("send addConversationListener"); + let add_listener_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(add_listener_id)), + ) + .await + .expect("addConversationListener timeout") + .expect("addConversationListener resp"); + let AddConversationSubscriptionResponse { subscription_id: _ } = + to_response::<_>(add_listener_resp).expect("deserialize addConversationListener response"); + + let send_id = mcp + .send_send_user_message_request(SendUserMessageParams { + conversation_id, + items: vec![InputItem::Text { + text: "Hello".to_string(), + }], + }) + .await + .expect("send sendUserMessage"); + + let instructions = read_raw_response_item(&mut mcp, conversation_id).await; + assert_instructions_message(&instructions); + + let environment = read_raw_response_item(&mut mcp, conversation_id).await; + assert_environment_message(&environment); + + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(send_id)), + ) + .await + .expect("sendUserMessage response timeout") + .expect("sendUserMessage response error"); + let _ok: SendUserMessageResponse = to_response::(response) + .expect("deserialize sendUserMessage response"); + + let user_message = read_raw_response_item(&mut mcp, conversation_id).await; + assert_user_message(&user_message, "Hello"); + + let assistant_message = read_raw_response_item(&mut mcp, conversation_id).await; + assert_assistant_message(&assistant_message, "Done"); + + let _ = tokio::time::timeout( + std::time::Duration::from_millis(250), + mcp.read_stream_until_notification_message("codex/event/task_complete"), + ) + .await; } #[tokio::test] @@ -184,3 +288,108 @@ stream_max_retries = 0 ), ) } + +#[expect(clippy::expect_used)] +async fn read_raw_response_item( + mcp: &mut McpProcess, + conversation_id: ConversationId, +) -> ResponseItem { + let raw_notification: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("codex/event/raw_response_item"), + ) + .await + .expect("codex/event/raw_response_item notification timeout") + .expect("codex/event/raw_response_item notification resp"); + + let serde_json::Value::Object(params) = raw_notification + .params + .expect("codex/event/raw_response_item should have params") + else { + panic!("codex/event/raw_response_item should have params"); + }; + + let conversation_id_value = params + .get("conversationId") + .and_then(|value| value.as_str()) + .expect("raw response item should include conversationId"); + + assert_eq!( + conversation_id_value, + conversation_id.to_string(), + "raw response item conversation mismatch" + ); + + let msg_value = params + .get("msg") + .cloned() + .expect("raw response item should include msg payload"); + + serde_json::from_value(msg_value).expect("deserialize raw response item") +} + +fn assert_instructions_message(item: &ResponseItem) { + match item { + ResponseItem::Message { role, content, .. } => { + assert_eq!(role, "user"); + let texts = content_texts(content); + assert!( + texts + .iter() + .any(|text| text.contains("")), + "expected instructions message, got {texts:?}" + ); + } + other => panic!("expected instructions message, got {other:?}"), + } +} + +fn assert_environment_message(item: &ResponseItem) { + match item { + ResponseItem::Message { role, content, .. } => { + assert_eq!(role, "user"); + let texts = content_texts(content); + assert!( + texts + .iter() + .any(|text| text.contains("")), + "expected environment context message, got {texts:?}" + ); + } + other => panic!("expected environment message, got {other:?}"), + } +} + +fn assert_user_message(item: &ResponseItem, expected_text: &str) { + match item { + ResponseItem::Message { role, content, .. } => { + assert_eq!(role, "user"); + let texts = content_texts(content); + assert_eq!(texts, vec![expected_text]); + } + other => panic!("expected user message, got {other:?}"), + } +} + +fn assert_assistant_message(item: &ResponseItem, expected_text: &str) { + match item { + ResponseItem::Message { role, content, .. } => { + assert_eq!(role, "assistant"); + let texts = content_texts(content); + assert_eq!(texts, vec![expected_text]); + } + other => panic!("expected assistant message, got {other:?}"), + } +} + +fn content_texts(content: &[ContentItem]) -> Vec<&str> { + content + .iter() + .filter_map(|item| match item { + ContentItem::InputText { text } | ContentItem::OutputText { text } => { + Some(text.as_str()) + } + _ => None, + }) + .collect() +} diff --git a/codex-rs/apply-patch/tests/suite/mod.rs b/codex-rs/apply-patch/tests/suite/mod.rs index 26710c101c..882c5a6ffd 100644 --- a/codex-rs/apply-patch/tests/suite/mod.rs +++ b/codex-rs/apply-patch/tests/suite/mod.rs @@ -1 +1,3 @@ mod cli; +#[cfg(not(target_os = "windows"))] +mod tool; diff --git a/codex-rs/apply-patch/tests/suite/tool.rs b/codex-rs/apply-patch/tests/suite/tool.rs new file mode 100644 index 0000000000..c937719da0 --- /dev/null +++ b/codex-rs/apply-patch/tests/suite/tool.rs @@ -0,0 +1,257 @@ +use assert_cmd::Command; +use pretty_assertions::assert_eq; +use std::fs; +use std::path::Path; +use tempfile::tempdir; + +fn run_apply_patch_in_dir(dir: &Path, patch: &str) -> anyhow::Result { + let mut cmd = Command::cargo_bin("apply_patch")?; + cmd.current_dir(dir); + Ok(cmd.arg(patch).assert()) +} + +fn apply_patch_command(dir: &Path) -> anyhow::Result { + let mut cmd = Command::cargo_bin("apply_patch")?; + cmd.current_dir(dir); + Ok(cmd) +} + +#[test] +fn test_apply_patch_cli_applies_multiple_operations() -> anyhow::Result<()> { + let tmp = tempdir()?; + let modify_path = tmp.path().join("modify.txt"); + let delete_path = tmp.path().join("delete.txt"); + + fs::write(&modify_path, "line1\nline2\n")?; + fs::write(&delete_path, "obsolete\n")?; + + let patch = "*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Delete File: delete.txt\n*** Update File: modify.txt\n@@\n-line2\n+changed\n*** End Patch"; + + run_apply_patch_in_dir(tmp.path(), patch)?.success().stdout( + "Success. Updated the following files:\nA nested/new.txt\nM modify.txt\nD delete.txt\n", + ); + + assert_eq!( + fs::read_to_string(tmp.path().join("nested/new.txt"))?, + "created\n" + ); + assert_eq!(fs::read_to_string(&modify_path)?, "line1\nchanged\n"); + assert!(!delete_path.exists()); + + Ok(()) +} + +#[test] +fn test_apply_patch_cli_applies_multiple_chunks() -> anyhow::Result<()> { + let tmp = tempdir()?; + let target_path = tmp.path().join("multi.txt"); + fs::write(&target_path, "line1\nline2\nline3\nline4\n")?; + + let patch = "*** Begin Patch\n*** Update File: multi.txt\n@@\n-line2\n+changed2\n@@\n-line4\n+changed4\n*** End Patch"; + + run_apply_patch_in_dir(tmp.path(), patch)? + .success() + .stdout("Success. Updated the following files:\nM multi.txt\n"); + + assert_eq!( + fs::read_to_string(&target_path)?, + "line1\nchanged2\nline3\nchanged4\n" + ); + + Ok(()) +} + +#[test] +fn test_apply_patch_cli_moves_file_to_new_directory() -> anyhow::Result<()> { + let tmp = tempdir()?; + let original_path = tmp.path().join("old/name.txt"); + let new_path = tmp.path().join("renamed/dir/name.txt"); + fs::create_dir_all(original_path.parent().expect("parent should exist"))?; + fs::write(&original_path, "old content\n")?; + + let patch = "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch"; + + run_apply_patch_in_dir(tmp.path(), patch)? + .success() + .stdout("Success. Updated the following files:\nM renamed/dir/name.txt\n"); + + assert!(!original_path.exists()); + assert_eq!(fs::read_to_string(&new_path)?, "new content\n"); + + Ok(()) +} + +#[test] +fn test_apply_patch_cli_rejects_empty_patch() -> anyhow::Result<()> { + let tmp = tempdir()?; + + apply_patch_command(tmp.path())? + .arg("*** Begin Patch\n*** End Patch") + .assert() + .failure() + .stderr("No files were modified.\n"); + + Ok(()) +} + +#[test] +fn test_apply_patch_cli_reports_missing_context() -> anyhow::Result<()> { + let tmp = tempdir()?; + let target_path = tmp.path().join("modify.txt"); + fs::write(&target_path, "line1\nline2\n")?; + + apply_patch_command(tmp.path())? + .arg("*** Begin Patch\n*** Update File: modify.txt\n@@\n-missing\n+changed\n*** End Patch") + .assert() + .failure() + .stderr("Failed to find expected lines in modify.txt:\nmissing\n"); + assert_eq!(fs::read_to_string(&target_path)?, "line1\nline2\n"); + + Ok(()) +} + +#[test] +fn test_apply_patch_cli_rejects_missing_file_delete() -> anyhow::Result<()> { + let tmp = tempdir()?; + + apply_patch_command(tmp.path())? + .arg("*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch") + .assert() + .failure() + .stderr("Failed to delete file missing.txt\n"); + + Ok(()) +} + +#[test] +fn test_apply_patch_cli_rejects_empty_update_hunk() -> anyhow::Result<()> { + let tmp = tempdir()?; + + apply_patch_command(tmp.path())? + .arg("*** Begin Patch\n*** Update File: foo.txt\n*** End Patch") + .assert() + .failure() + .stderr("Invalid patch hunk on line 2: Update file hunk for path 'foo.txt' is empty\n"); + + Ok(()) +} + +#[test] +fn test_apply_patch_cli_requires_existing_file_for_update() -> anyhow::Result<()> { + let tmp = tempdir()?; + + apply_patch_command(tmp.path())? + .arg("*** Begin Patch\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch") + .assert() + .failure() + .stderr( + "Failed to read file to update missing.txt: No such file or directory (os error 2)\n", + ); + + Ok(()) +} + +#[test] +fn test_apply_patch_cli_move_overwrites_existing_destination() -> anyhow::Result<()> { + let tmp = tempdir()?; + let original_path = tmp.path().join("old/name.txt"); + let destination = tmp.path().join("renamed/dir/name.txt"); + fs::create_dir_all(original_path.parent().expect("parent should exist"))?; + fs::create_dir_all(destination.parent().expect("parent should exist"))?; + fs::write(&original_path, "from\n")?; + fs::write(&destination, "existing\n")?; + + run_apply_patch_in_dir( + tmp.path(), + "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-from\n+new\n*** End Patch", + )? + .success() + .stdout("Success. Updated the following files:\nM renamed/dir/name.txt\n"); + + assert!(!original_path.exists()); + assert_eq!(fs::read_to_string(&destination)?, "new\n"); + + Ok(()) +} + +#[test] +fn test_apply_patch_cli_add_overwrites_existing_file() -> anyhow::Result<()> { + let tmp = tempdir()?; + let path = tmp.path().join("duplicate.txt"); + fs::write(&path, "old content\n")?; + + run_apply_patch_in_dir( + tmp.path(), + "*** Begin Patch\n*** Add File: duplicate.txt\n+new content\n*** End Patch", + )? + .success() + .stdout("Success. Updated the following files:\nA duplicate.txt\n"); + + assert_eq!(fs::read_to_string(&path)?, "new content\n"); + + Ok(()) +} + +#[test] +fn test_apply_patch_cli_delete_directory_fails() -> anyhow::Result<()> { + let tmp = tempdir()?; + fs::create_dir(tmp.path().join("dir"))?; + + apply_patch_command(tmp.path())? + .arg("*** Begin Patch\n*** Delete File: dir\n*** End Patch") + .assert() + .failure() + .stderr("Failed to delete file dir\n"); + + Ok(()) +} + +#[test] +fn test_apply_patch_cli_rejects_invalid_hunk_header() -> anyhow::Result<()> { + let tmp = tempdir()?; + + apply_patch_command(tmp.path())? + .arg("*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch") + .assert() + .failure() + .stderr("Invalid patch hunk on line 2: '*** Frobnicate File: foo' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'\n"); + + Ok(()) +} + +#[test] +fn test_apply_patch_cli_updates_file_appends_trailing_newline() -> anyhow::Result<()> { + let tmp = tempdir()?; + let target_path = tmp.path().join("no_newline.txt"); + fs::write(&target_path, "no newline at end")?; + + run_apply_patch_in_dir( + tmp.path(), + "*** Begin Patch\n*** Update File: no_newline.txt\n@@\n-no newline at end\n+first line\n+second line\n*** End Patch", + )? + .success() + .stdout("Success. Updated the following files:\nM no_newline.txt\n"); + + let contents = fs::read_to_string(&target_path)?; + assert!(contents.ends_with('\n')); + assert_eq!(contents, "first line\nsecond line\n"); + + Ok(()) +} + +#[test] +fn test_apply_patch_cli_failure_after_partial_success_leaves_changes() -> anyhow::Result<()> { + let tmp = tempdir()?; + let new_file = tmp.path().join("created.txt"); + + apply_patch_command(tmp.path())? + .arg("*** Begin Patch\n*** Add File: created.txt\n+hello\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch") + .assert() + .failure() + .stdout("") + .stderr("Failed to read file to update missing.txt: No such file or directory (os error 2)\n"); + + assert_eq!(fs::read_to_string(&new_file)?, "hello\n"); + + Ok(()) +} diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index a4eefb9fd8..21676dece0 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -274,19 +274,33 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re http_headers, env_http_headers, } = transport - && matches!(supports_oauth_login(&url).await, Ok(true)) { - println!("Detected OAuth support. Starting OAuth flow…"); - perform_oauth_login( - &name, - &url, - config.mcp_oauth_credentials_store_mode, - http_headers.clone(), - env_http_headers.clone(), - &Vec::new(), - ) - .await?; - println!("Successfully logged in."); + match supports_oauth_login(&url).await { + Ok(true) => { + if !config.features.enabled(Feature::RmcpClient) { + println!( + "MCP server supports login. Add `experimental_use_rmcp_client = true` \ + to your config.toml and run `codex mcp login {name}` to login." + ); + } else { + println!("Detected OAuth support. Starting OAuth flow…"); + perform_oauth_login( + &name, + &url, + config.mcp_oauth_credentials_store_mode, + http_headers.clone(), + env_http_headers.clone(), + &Vec::new(), + ) + .await?; + println!("Successfully logged in."); + } + } + Ok(false) => {} + Err(_) => println!( + "MCP server may or may not require login. Run `codex mcp login {name}` to login." + ), + } } Ok(()) @@ -523,10 +537,12 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> .map(|entry| entry.auth_status) .unwrap_or(McpAuthStatus::Unsupported) .to_string(); + let bearer_token_display = + bearer_token_env_var.as_deref().unwrap_or("-").to_string(); http_rows.push([ name.clone(), url.clone(), - bearer_token_env_var.clone().unwrap_or("-".to_string()), + bearer_token_display, status, auth_status, ]); @@ -752,15 +768,15 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re } => { println!(" transport: streamable_http"); println!(" url: {url}"); - let env_var = bearer_token_env_var.as_deref().unwrap_or("-"); - println!(" bearer_token_env_var: {env_var}"); + let bearer_token_display = bearer_token_env_var.as_deref().unwrap_or("-"); + println!(" bearer_token_env_var: {bearer_token_display}"); let headers_display = match http_headers { Some(map) if !map.is_empty() => { let mut pairs: Vec<_> = map.iter().collect(); pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); pairs .into_iter() - .map(|(k, v)| format!("{k}={v}")) + .map(|(k, _)| format!("{k}=*****")) .collect::>() .join(", ") } @@ -773,7 +789,7 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); pairs .into_iter() - .map(|(k, v)| format!("{k}={v}")) + .map(|(k, var)| format!("{k}={var}")) .collect::>() .join(", ") } diff --git a/codex-rs/cli/tests/mcp_list.rs b/codex-rs/cli/tests/mcp_list.rs index 6440660cdf..553c731576 100644 --- a/codex-rs/cli/tests/mcp_list.rs +++ b/codex-rs/cli/tests/mcp_list.rs @@ -68,9 +68,9 @@ async fn list_and_get_render_expected_output() -> Result<()> { assert!(stdout.contains("Name")); assert!(stdout.contains("docs")); assert!(stdout.contains("docs-server")); - assert!(stdout.contains("TOKEN=secret")); - assert!(stdout.contains("APP_TOKEN=$APP_TOKEN")); - assert!(stdout.contains("WORKSPACE_ID=$WORKSPACE_ID")); + assert!(stdout.contains("TOKEN=*****")); + assert!(stdout.contains("APP_TOKEN=*****")); + assert!(stdout.contains("WORKSPACE_ID=*****")); assert!(stdout.contains("Status")); assert!(stdout.contains("Auth")); assert!(stdout.contains("enabled")); @@ -119,9 +119,9 @@ async fn list_and_get_render_expected_output() -> Result<()> { assert!(stdout.contains("transport: stdio")); assert!(stdout.contains("command: docs-server")); assert!(stdout.contains("args: --port 4000")); - assert!(stdout.contains("env: TOKEN=secret")); - assert!(stdout.contains("APP_TOKEN=$APP_TOKEN")); - assert!(stdout.contains("WORKSPACE_ID=$WORKSPACE_ID")); + assert!(stdout.contains("env: TOKEN=*****")); + assert!(stdout.contains("APP_TOKEN=*****")); + assert!(stdout.contains("WORKSPACE_ID=*****")); assert!(stdout.contains("enabled: true")); assert!(stdout.contains("remove: codex mcp remove docs")); diff --git a/codex-rs/common/src/format_env_display.rs b/codex-rs/common/src/format_env_display.rs index 640be30783..fa35bdce25 100644 --- a/codex-rs/common/src/format_env_display.rs +++ b/codex-rs/common/src/format_env_display.rs @@ -6,15 +6,11 @@ pub fn format_env_display(env: Option<&HashMap>, env_vars: &[Str if let Some(map) = env { let mut pairs: Vec<_> = map.iter().collect(); pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); - parts.extend( - pairs - .into_iter() - .map(|(key, value)| format!("{key}={value}")), - ); + parts.extend(pairs.into_iter().map(|(key, _)| format!("{key}=*****"))); } if !env_vars.is_empty() { - parts.extend(env_vars.iter().map(|var| format!("{var}=${var}"))); + parts.extend(env_vars.iter().map(|var| format!("{var}=*****"))); } if parts.is_empty() { @@ -42,14 +38,14 @@ mod tests { env.insert("B".to_string(), "two".to_string()); env.insert("A".to_string(), "one".to_string()); - assert_eq!(format_env_display(Some(&env), &[]), "A=one, B=two"); + assert_eq!(format_env_display(Some(&env), &[]), "A=*****, B=*****"); } #[test] fn formats_env_vars_with_dollar_prefix() { let vars = vec!["TOKEN".to_string(), "PATH".to_string()]; - assert_eq!(format_env_display(None, &vars), "TOKEN=$TOKEN, PATH=$PATH"); + assert_eq!(format_env_display(None, &vars), "TOKEN=*****, PATH=*****"); } #[test] @@ -60,7 +56,7 @@ mod tests { assert_eq!( format_env_display(Some(&env), &vars), - "HOME=/tmp, TOKEN=$TOKEN" + "HOME=*****, TOKEN=*****" ); } } diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 36d5358605..92e63b1fe5 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -24,15 +24,19 @@ codex-apply-patch = { workspace = true } codex-file-search = { workspace = true } codex-otel = { workspace = true, features = ["otel"] } codex-protocol = { workspace = true } +codex-git-tooling = { workspace = true } codex-rmcp-client = { workspace = true } codex-async-utils = { workspace = true } codex-utils-string = { workspace = true } codex-utils-pty = { workspace = true } +codex-utils-readiness = { workspace = true } +codex-utils-tokenizer = { workspace = true } dirs = { workspace = true } dunce = { workspace = true } env-flags = { workspace = true } eventsource-stream = { workspace = true } futures = { workspace = true } +http = { workspace = true } indexmap = { workspace = true } libc = { workspace = true } mcp-types = { workspace = true } diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index 3dcc037bd9..55386ca5ec 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -21,6 +21,7 @@ use codex_app_server_protocol::AuthMode; use codex_protocol::config_types::ForcedLoginMethod; use crate::config::Config; +use crate::default_client::CodexHttpClient; use crate::token_data::PlanType; use crate::token_data::TokenData; use crate::token_data::parse_id_token; @@ -32,7 +33,7 @@ pub struct CodexAuth { pub(crate) api_key: Option, pub(crate) auth_dot_json: Arc>>, pub(crate) auth_file: PathBuf, - pub(crate) client: reqwest::Client, + pub(crate) client: CodexHttpClient, } impl PartialEq for CodexAuth { @@ -43,6 +44,8 @@ impl PartialEq for CodexAuth { impl CodexAuth { pub async fn refresh_token(&self) -> Result { + tracing::info!("Refreshing token"); + let token_data = self .get_current_token_data() .ok_or(std::io::Error::other("Token data is not available."))?; @@ -180,7 +183,7 @@ impl CodexAuth { } } - fn from_api_key_with_client(api_key: &str, client: reqwest::Client) -> Self { + fn from_api_key_with_client(api_key: &str, client: CodexHttpClient) -> Self { Self { api_key: Some(api_key.to_owned()), mode: AuthMode::ApiKey, @@ -400,7 +403,7 @@ async fn update_tokens( async fn try_refresh_token( refresh_token: String, - client: &reqwest::Client, + client: &CodexHttpClient, ) -> std::io::Result { let refresh_request = RefreshRequest { client_id: CLIENT_ID, @@ -916,7 +919,10 @@ impl AuthManager { self.reload(); Ok(Some(token)) } - Err(e) => Err(e), + Err(e) => { + tracing::error!("Failed to refresh token: {}", e); + Err(e) + } } } diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs index 27937a48d0..76cbdf9f0a 100644 --- a/codex-rs/core/src/chat_completions.rs +++ b/codex-rs/core/src/chat_completions.rs @@ -4,6 +4,7 @@ use crate::ModelProviderInfo; use crate::client_common::Prompt; use crate::client_common::ResponseEvent; use crate::client_common::ResponseStream; +use crate::default_client::CodexHttpClient; use crate::error::CodexErr; use crate::error::ConnectionFailedError; use crate::error::ResponseStreamFailed; @@ -36,7 +37,7 @@ use tracing::trace; pub(crate) async fn stream_chat_completions( prompt: &Prompt, model_family: &ModelFamily, - client: &reqwest::Client, + client: &CodexHttpClient, provider: &ModelProviderInfo, otel_event_manager: &OtelEventManager, ) -> Result { @@ -75,6 +76,7 @@ pub(crate) async fn stream_chat_completions( ResponseItem::CustomToolCall { .. } => {} ResponseItem::CustomToolCallOutput { .. } => {} ResponseItem::WebSearchCall { .. } => {} + ResponseItem::GhostSnapshot { .. } => {} } } @@ -269,6 +271,10 @@ pub(crate) async fn stream_chat_completions( "content": output, })); } + ResponseItem::GhostSnapshot { .. } => { + // Ghost snapshots annotate history but are not sent to the model. + continue; + } ResponseItem::Reasoning { .. } | ResponseItem::WebSearchCall { .. } | ResponseItem::Other => { diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 1bf1ad0d8b..de10d6406f 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -39,6 +39,7 @@ use crate::client_common::ResponsesApiRequest; use crate::client_common::create_reasoning_param_for_request; use crate::client_common::create_text_param_for_request; use crate::config::Config; +use crate::default_client::CodexHttpClient; use crate::default_client::create_client; use crate::error::CodexErr; use crate::error::ConnectionFailedError; @@ -81,7 +82,7 @@ pub struct ModelClient { config: Arc, auth_manager: Option>, otel_event_manager: OtelEventManager, - client: reqwest::Client, + client: CodexHttpClient, provider: ModelProviderInfo, conversation_id: ConversationId, effort: Option, @@ -133,6 +134,14 @@ impl ModelClient { self.stream_with_task_kind(prompt, TaskKind::Regular).await } + pub fn config(&self) -> Arc { + Arc::clone(&self.config) + } + + pub fn provider(&self) -> &ModelProviderInfo { + &self.provider + } + pub(crate) async fn stream_with_task_kind( &self, prompt: &Prompt, @@ -300,6 +309,7 @@ impl ModelClient { "POST to {}: {:?}", self.provider.get_full_url(&auth), serde_json::to_string(payload_json) + .unwrap_or("".to_string()) ); let mut req_builder = self @@ -335,13 +345,6 @@ impl ModelClient { .headers() .get("cf-ray") .map(|v| v.to_str().unwrap_or_default().to_string()); - - debug!( - "Response status: {}, cf-ray: {:?}, version: {:?}", - resp.status(), - request_id, - resp.version() - ); } match res { diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b261b083ca..65dad461fa 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -8,8 +8,10 @@ use crate::AuthManager; use crate::client_common::REVIEW_PROMPT; use crate::function_tool::FunctionCallError; use crate::mcp::auth::McpAuthStatusEntry; +use crate::mcp_connection_manager::DEFAULT_STARTUP_TIMEOUT; use crate::parse_command::parse_command; use crate::parse_turn_item; +use crate::response_processing::process_items; use crate::review_format::format_review_findings_block; use crate::terminal; use crate::user_notification::UserNotifier; @@ -18,7 +20,6 @@ use async_channel::Sender; use codex_apply_patch::ApplyPatchAction; use codex_protocol::ConversationId; use codex_protocol::items::TurnItem; -use codex_protocol::protocol::ConversationPathResponseEvent; use codex_protocol::protocol::ExitedReviewModeEvent; use codex_protocol::protocol::ItemCompletedEvent; use codex_protocol::protocol::ItemStartedEvent; @@ -86,6 +87,7 @@ use crate::protocol::Op; use crate::protocol::RateLimitSnapshot; use crate::protocol::ReviewDecision; use crate::protocol::ReviewOutputEvent; +use crate::protocol::SandboxCommandAssessment; use crate::protocol::SandboxPolicy; use crate::protocol::SessionConfiguredEvent; use crate::protocol::StreamErrorEvent; @@ -102,8 +104,12 @@ use crate::state::SessionServices; use crate::state::SessionState; use crate::state::TaskKind; use crate::tasks::CompactTask; +use crate::tasks::GhostSnapshotTask; use crate::tasks::RegularTask; use crate::tasks::ReviewTask; +use crate::tasks::SessionTask; +use crate::tasks::SessionTaskContext; +use crate::tasks::UndoTask; use crate::tools::ToolRouter; use crate::tools::context::SharedTurnDiffTracker; use crate::tools::parallel::ToolCallRuntime; @@ -126,6 +132,8 @@ use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::InitialHistory; use codex_protocol::user_input::UserInput; +use codex_utils_readiness::Readiness; +use codex_utils_readiness::ReadinessFlag; pub mod compact; use self::compact::build_compacted_history; @@ -176,6 +184,7 @@ impl Codex { sandbox_policy: config.sandbox_policy.clone(), cwd: config.cwd.clone(), original_config_do_not_use: Arc::clone(&config), + features: config.features.clone(), }; // Generate a unique ID for the lifetime of this Codex session. @@ -269,6 +278,7 @@ pub(crate) struct TurnContext { pub(crate) is_review_mode: bool, pub(crate) final_output_json_schema: Option, pub(crate) codex_linux_sandbox_exe: Option, + pub(crate) tool_call_gate: Arc, } impl TurnContext { @@ -310,6 +320,9 @@ pub(crate) struct SessionConfiguration { /// operate deterministically. cwd: PathBuf, + /// Set of feature flags for this session + features: Features, + // TODO(pakrym): Remove config from here original_config_do_not_use: Arc, } @@ -404,6 +417,7 @@ impl Session { is_review_mode: false, final_output_json_schema: None, codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), + tool_call_gate: Arc::new(ReadinessFlag::new()), } } @@ -567,7 +581,6 @@ impl Session { // Dispatch the SessionConfiguredEvent first and then report any errors. // If resuming, include converted initial messages in the payload so UIs can render them immediately. let initial_messages = initial_history.get_event_msgs(); - sess.record_initial_history(initial_history).await; let events = std::iter::once(Event { id: INITIAL_SUBMIT_ID.to_owned(), @@ -586,6 +599,9 @@ impl Session { sess.send_event_raw(event).await; } + // record_initial_history can emit events. We record only after the SessionConfiguredEvent is emitted. + sess.record_initial_history(initial_history).await; + Ok(sess) } @@ -593,6 +609,19 @@ impl Session { self.tx_event.clone() } + /// Ensure all rollout writes are durably flushed. + pub(crate) async fn flush_rollout(&self) { + let recorder = { + let guard = self.services.rollout.lock().await; + guard.clone() + }; + if let Some(rec) = recorder + && let Err(e) = rec.flush().await + { + warn!("failed to flush rollout recorder: {e}"); + } + } + fn next_internal_sub_id(&self) -> String { let id = self .next_internal_sub_id @@ -606,7 +635,9 @@ impl Session { InitialHistory::New => { // Build and record initial items (user instructions + environment context) let items = self.build_initial_context(&turn_context); - self.record_conversation_items(&items).await; + self.record_conversation_items(&turn_context, &items).await; + // Ensure initial items are visible to immediate readers (e.g., tests, forks). + self.flush_rollout().await; } InitialHistory::Resumed(_) | InitialHistory::Forked(_) => { let rollout_items = conversation_history.get_rollout_items(); @@ -623,6 +654,8 @@ impl Session { if persist && !rollout_items.is_empty() { self.persist_rollout_items(&rollout_items).await; } + // Flush after seeding history and any persisted rollout copy. + self.flush_rollout().await; } } } @@ -753,6 +786,32 @@ impl Session { } } + pub(crate) async fn assess_sandbox_command( + &self, + turn_context: &TurnContext, + call_id: &str, + command: &[String], + failure_message: Option<&str>, + ) -> Option { + let config = turn_context.client.config(); + let provider = turn_context.client.provider().clone(); + let auth_manager = Arc::clone(&self.services.auth_manager); + let otel = self.services.otel_event_manager.clone(); + crate::sandboxing::assessment::assess_command( + config, + provider, + auth_manager, + &otel, + self.conversation_id, + call_id, + command, + &turn_context.sandbox_policy, + &turn_context.cwd, + failure_message, + ) + .await + } + /// Emit an exec approval request event and await the user's decision. /// /// The request is keyed by `sub_id`/`call_id` so matching responses are delivered @@ -765,6 +824,7 @@ impl Session { command: Vec, cwd: PathBuf, reason: Option, + risk: Option, ) -> ReviewDecision { let sub_id = turn_context.sub_id.clone(); // Add the tx_approve callback to the map before sending the request. @@ -790,6 +850,7 @@ impl Session { command, cwd, reason, + risk, parsed_cmd, }); self.send_event(turn_context, event).await; @@ -855,9 +916,14 @@ impl Session { /// Records input items: always append to conversation history and /// persist these response items to rollout. - async fn record_conversation_items(&self, items: &[ResponseItem]) { + pub(crate) async fn record_conversation_items( + &self, + turn_context: &TurnContext, + items: &[ResponseItem], + ) { self.record_into_history(items).await; self.persist_rollout_response_items(items).await; + self.send_raw_response_items(turn_context, items).await; } fn reconstruct_history_from_rollout( @@ -893,7 +959,7 @@ impl Session { state.record_items(items.iter()); } - async fn replace_history(&self, items: Vec) { + pub(crate) async fn replace_history(&self, items: Vec) { let mut state = self.state.lock().await; state.replace_history(items); } @@ -907,6 +973,13 @@ impl Session { self.persist_rollout_items(&rollout_items).await; } + async fn send_raw_response_items(&self, turn_context: &TurnContext, items: &[ResponseItem]) { + for item in items { + self.send_event(turn_context, EventMsg::RawResponseItem(item.clone())) + .await; + } + } + pub(crate) fn build_initial_context(&self, turn_context: &TurnContext) -> Vec { let mut items = Vec::::with_capacity(2); if let Some(user_instructions) = turn_context.user_instructions.as_deref() { @@ -1002,7 +1075,7 @@ impl Session { ) { let response_item: ResponseItem = response_input.clone().into(); // Add to conversation history and persist response item to rollout - self.record_conversation_items(std::slice::from_ref(&response_item)) + self.record_conversation_items(turn_context, std::slice::from_ref(&response_item)) .await; // Derive user message events and persist only UserMessage to rollout @@ -1035,6 +1108,43 @@ impl Session { self.send_event(turn_context, event).await; } + async fn maybe_start_ghost_snapshot( + self: &Arc, + turn_context: Arc, + cancellation_token: CancellationToken, + ) { + if turn_context.is_review_mode + || !self + .state + .lock() + .await + .session_configuration + .features + .enabled(Feature::GhostCommit) + { + return; + } + + let token = match turn_context.tool_call_gate.subscribe().await { + Ok(token) => token, + Err(err) => { + warn!("failed to subscribe to ghost snapshot readiness: {err}"); + return; + } + }; + + info!("spawning ghost snapshot task"); + let task = GhostSnapshotTask::new(token); + Arc::new(task) + .run( + Arc::new(SessionTaskContext::new(self.clone())), + turn_context.clone(), + Vec::new(), + cancellation_token, + ) + .await; + } + /// Returns the input if there was no task running to inject into pub async fn inject_input(&self, input: Vec) -> Result<(), Vec> { let mut active = self.active_turn.lock().await; @@ -1193,8 +1303,11 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv if let Some(env_item) = sess .build_environment_update_item(previous_context.as_ref(), ¤t_context) { - sess.record_conversation_items(std::slice::from_ref(&env_item)) - .await; + sess.record_conversation_items( + ¤t_context, + std::slice::from_ref(&env_item), + ) + .await; } sess.spawn_task(Arc::clone(¤t_context), items, RegularTask) @@ -1308,6 +1421,13 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv }; sess.send_event_raw(event).await; } + Op::Undo => { + let turn_context = sess + .new_turn_with_sub_id(sub.id.clone(), SessionSettingsUpdate::default()) + .await; + sess.spawn_task(turn_context, Vec::new(), UndoTask::new()) + .await; + } Op::Compact => { let turn_context = sess .new_turn_with_sub_id(sub.id.clone(), SessionSettingsUpdate::default()) @@ -1353,33 +1473,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv sess.send_event_raw(event).await; break; } - Op::GetPath => { - let sub_id = sub.id.clone(); - // Flush rollout writes before returning the path so readers observe a consistent file. - let (path, rec_opt) = { - let guard = sess.services.rollout.lock().await; - match guard.as_ref() { - Some(rec) => (rec.get_rollout_path(), Some(rec.clone())), - None => { - error!("rollout recorder not found"); - continue; - } - } - }; - if let Some(rec) = rec_opt - && let Err(e) = rec.flush().await - { - warn!("failed to flush rollout recorder before GetHistory: {e}"); - } - let event = Event { - id: sub_id.clone(), - msg: EventMsg::ConversationPath(ConversationPathResponseEvent { - conversation_id: sess.conversation_id, - path, - }), - }; - sess.send_event_raw(event).await; - } + Op::Review { review_request } => { let turn_context = sess .new_turn_with_sub_id(sub.id.clone(), SessionSettingsUpdate::default()) @@ -1470,6 +1564,7 @@ async fn spawn_review_thread( is_review_mode: true, final_output_json_schema: None, codex_linux_sandbox_exe: parent_turn_context.codex_linux_sandbox_exe.clone(), + tool_call_gate: Arc::new(ReadinessFlag::new()), }; // Seed the child task with the review prompt as the initial user message. @@ -1533,6 +1628,8 @@ pub(crate) async fn run_task( .await; } + sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) + .await; let mut last_agent_message: Option = None; // Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains // many turns, from the perspective of the user, it is a single turn. @@ -1566,7 +1663,8 @@ pub(crate) async fn run_task( } review_thread_history.get_history() } else { - sess.record_conversation_items(&pending_input).await; + sess.record_conversation_items(&turn_context, &pending_input) + .await; sess.history_snapshot().await }; @@ -1608,109 +1706,14 @@ pub(crate) async fn run_task( let token_limit_reached = total_usage_tokens .map(|tokens| tokens >= limit) .unwrap_or(false); - let mut items_to_record_in_conversation_history = Vec::::new(); - let mut responses = Vec::::new(); - for processed_response_item in processed_items { - let ProcessedResponseItem { item, response } = processed_response_item; - match (&item, &response) { - (ResponseItem::Message { role, .. }, None) if role == "assistant" => { - // If the model returned a message, we need to record it. - items_to_record_in_conversation_history.push(item); - } - ( - ResponseItem::LocalShellCall { .. }, - Some(ResponseInputItem::FunctionCallOutput { call_id, output }), - ) => { - items_to_record_in_conversation_history.push(item); - items_to_record_in_conversation_history.push( - ResponseItem::FunctionCallOutput { - call_id: call_id.clone(), - output: output.clone(), - }, - ); - } - ( - ResponseItem::FunctionCall { .. }, - Some(ResponseInputItem::FunctionCallOutput { call_id, output }), - ) => { - items_to_record_in_conversation_history.push(item); - items_to_record_in_conversation_history.push( - ResponseItem::FunctionCallOutput { - call_id: call_id.clone(), - output: output.clone(), - }, - ); - } - ( - ResponseItem::CustomToolCall { .. }, - Some(ResponseInputItem::CustomToolCallOutput { call_id, output }), - ) => { - items_to_record_in_conversation_history.push(item); - items_to_record_in_conversation_history.push( - ResponseItem::CustomToolCallOutput { - call_id: call_id.clone(), - output: output.clone(), - }, - ); - } - ( - ResponseItem::FunctionCall { .. }, - Some(ResponseInputItem::McpToolCallOutput { call_id, result }), - ) => { - items_to_record_in_conversation_history.push(item); - let output = match result { - Ok(call_tool_result) => { - convert_call_tool_result_to_function_call_output_payload( - call_tool_result, - ) - } - Err(err) => FunctionCallOutputPayload { - content: err.clone(), - success: Some(false), - }, - }; - items_to_record_in_conversation_history.push( - ResponseItem::FunctionCallOutput { - call_id: call_id.clone(), - output, - }, - ); - } - ( - ResponseItem::Reasoning { - id, - summary, - content, - encrypted_content, - }, - None, - ) => { - items_to_record_in_conversation_history.push(ResponseItem::Reasoning { - id: id.clone(), - summary: summary.clone(), - content: content.clone(), - encrypted_content: encrypted_content.clone(), - }); - } - _ => { - warn!("Unexpected response item: {item:?} with response: {response:?}"); - } - }; - if let Some(response) = response { - responses.push(response); - } - } - - // Only attempt to take the lock if there is something to record. - if !items_to_record_in_conversation_history.is_empty() { - if is_review_mode { - review_thread_history - .record_items(items_to_record_in_conversation_history.iter()); - } else { - sess.record_conversation_items(&items_to_record_in_conversation_history) - .await; - } - } + let (responses, items_to_record_in_conversation_history) = process_items( + processed_items, + is_review_mode, + &mut review_thread_history, + &sess, + &turn_context, + ) + .await; if token_limit_reached { if auto_compact_recently_attempted { @@ -1749,7 +1752,17 @@ pub(crate) async fn run_task( } continue; } - Err(CodexErr::TurnAborted) => { + Err(CodexErr::TurnAborted { + dangling_artifacts: processed_items, + }) => { + let _ = process_items( + processed_items, + is_review_mode, + &mut review_thread_history, + &sess, + &turn_context, + ) + .await; // Aborted turn is reported via a different event. break; } @@ -1809,6 +1822,13 @@ fn parse_review_output_event(text: &str) -> ReviewOutputEvent { } } +fn filter_model_visible_history(input: Vec) -> Vec { + input + .into_iter() + .filter(|item| !matches!(item, ResponseItem::GhostSnapshot { .. })) + .collect() +} + async fn run_turn( sess: Arc, turn_context: Arc, @@ -1829,7 +1849,7 @@ async fn run_turn( .supports_parallel_tool_calls; let parallel_tool_calls = model_supports_parallel; let prompt = Prompt { - input, + input: filter_model_visible_history(input), tools: router.specs(), parallel_tool_calls, base_instructions_override: turn_context.base_instructions.clone(), @@ -1850,7 +1870,13 @@ async fn run_turn( .await { Ok(output) => return Ok(output), - Err(CodexErr::TurnAborted) => return Err(CodexErr::TurnAborted), + Err(CodexErr::TurnAborted { + dangling_artifacts: processed_items, + }) => { + return Err(CodexErr::TurnAborted { + dangling_artifacts: processed_items, + }); + } Err(CodexErr::Interrupted) => return Err(CodexErr::Interrupted), Err(CodexErr::EnvVar(var)) => return Err(CodexErr::EnvVar(var)), Err(e @ CodexErr::Fatal(_)) => return Err(e), @@ -1903,9 +1929,9 @@ async fn run_turn( /// "handled" such that it produces a `ResponseInputItem` that needs to be /// sent back to the model on the next turn. #[derive(Debug)] -pub(crate) struct ProcessedResponseItem { - pub(crate) item: ResponseItem, - pub(crate) response: Option, +pub struct ProcessedResponseItem { + pub item: ResponseItem, + pub response: Option, } #[derive(Debug)] @@ -1954,7 +1980,15 @@ async fn try_run_turn( // Poll the next item from the model stream. We must inspect *both* Ok and Err // cases so that transient stream failures (e.g., dropped SSE connection before // `response.completed`) bubble up and trigger the caller's retry logic. - let event = stream.next().or_cancel(&cancellation_token).await?; + let event = match stream.next().or_cancel(&cancellation_token).await { + Ok(event) => event, + Err(codex_async_utils::CancelErr::Cancelled) => { + let processed_items = output.try_collect().await?; + return Err(CodexErr::TurnAborted { + dangling_artifacts: processed_items, + }); + } + }; let event = match event { Some(res) => res?, @@ -1978,7 +2012,8 @@ async fn try_run_turn( let payload_preview = call.payload.log_payload().into_owned(); tracing::info!("ToolCall: {} {}", call.tool_name, payload_preview); - let response = tool_runtime.handle_tool_call(call); + let response = + tool_runtime.handle_tool_call(call, cancellation_token.child_token()); output.push_back( async move { @@ -2060,12 +2095,7 @@ async fn try_run_turn( } => { sess.update_token_usage_info(turn_context.as_ref(), token_usage.as_ref()) .await; - - let processed_items = output - .try_collect() - .or_cancel(&cancellation_token) - .await??; - + let processed_items = output.try_collect().await?; let unified_diff = { let mut tracker = turn_diff_tracker.lock().await; tracker.get_unified_diff() @@ -2169,7 +2199,7 @@ pub(super) fn get_last_assistant_message_from_turn(responses: &[ResponseItem]) - } }) } -fn convert_call_tool_result_to_function_call_output_payload( +pub(crate) fn convert_call_tool_result_to_function_call_output_payload( call_tool_result: &CallToolResult, ) -> FunctionCallOutputPayload { let CallToolResult { @@ -2248,12 +2278,17 @@ pub(crate) async fn exit_review_mode( } session - .record_conversation_items(&[ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { text: user_message }], - }]) + .record_conversation_items( + &turn_context, + &[ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { text: user_message }], + }], + ) .await; + // Make the recorded review note visible immediately for readers. + session.flush_rollout().await; } fn mcp_init_error_display( @@ -2275,12 +2310,24 @@ fn mcp_init_error_display( // That means that the user has to specify a personal access token either via bearer_token_env_var or http_headers. // https://github.com/github/github-mcp-server/issues/921#issuecomment-3221026448 format!( - "GitHub MCP does not support OAuth. Log in by adding `bearer_token_env_var = CODEX_GITHUB_PAT` in the `mcp_servers.{server_name}` section of your config.toml" + "GitHub MCP does not support OAuth. Log in by adding a personal access token (https://github.com/settings/personal-access-tokens) to your environment and config.toml:\n[mcp_servers.{server_name}]\nbearer_token_env_var = CODEX_GITHUB_PERSONAL_ACCESS_TOKEN" ) } else if is_mcp_client_auth_required_error(err) { format!( "The {server_name} MCP server is not logged in. Run `codex mcp login {server_name}`." ) + } else if is_mcp_client_startup_timeout_error(err) { + let startup_timeout_secs = match entry { + Some(entry) => match entry.config.startup_timeout_sec { + Some(timeout) => timeout, + None => DEFAULT_STARTUP_TIMEOUT, + }, + None => DEFAULT_STARTUP_TIMEOUT, + } + .as_secs(); + format!( + "MCP client for `{server_name}` timed out after {startup_timeout_secs} seconds. Add or adjust `startup_timeout_sec` in your config.toml:\n[mcp_servers.{server_name}]\nstartup_timeout_sec = XX" + ) } else { format!("MCP client for `{server_name}` failed to start: {err:#}") } @@ -2291,6 +2338,14 @@ fn is_mcp_client_auth_required_error(error: &anyhow::Error) -> bool { error.to_string().contains("Auth required") } +fn is_mcp_client_startup_timeout_error(error: &anyhow::Error) -> bool { + let error_message = error.to_string(); + error_message.contains("request timed out") + || error_message.contains("timed out handshaking with MCP server") +} + +use crate::features::Feature; +use crate::features::Features; #[cfg(test)] pub(crate) use tests::make_session_and_context; @@ -2607,6 +2662,7 @@ mod tests { sandbox_policy: config.sandbox_policy.clone(), cwd: config.cwd.clone(), original_config_do_not_use: Arc::clone(&config), + features: Features::default(), }; let state = SessionState::new(session_configuration.clone()); @@ -2675,6 +2731,7 @@ mod tests { sandbox_policy: config.sandbox_policy.clone(), cwd: config.cwd.clone(), original_config_do_not_use: Arc::clone(&config), + features: Features::default(), }; let state = SessionState::new(session_configuration.clone()); @@ -2829,13 +2886,19 @@ mod tests { EventMsg::ExitedReviewMode(ev) => assert!(ev.review_output.is_none()), other => panic!("unexpected first event: {other:?}"), } - let second = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) - .await - .expect("timeout waiting for second event") - .expect("second event"); - match second.msg { - EventMsg::TurnAborted(e) => assert_eq!(TurnAbortReason::Interrupted, e.reason), - other => panic!("unexpected second event: {other:?}"), + loop { + let evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("timeout waiting for next event") + .expect("event"); + match evt.msg { + EventMsg::RawResponseItem(_) => continue, + EventMsg::TurnAborted(e) => { + assert_eq!(TurnAbortReason::Interrupted, e.reason); + break; + } + other => panic!("unexpected second event: {other:?}"), + } } let history = sess.history_snapshot().await; @@ -3148,7 +3211,7 @@ mod tests { let display = mcp_init_error_display(server_name, Some(&entry), &err); let expected = format!( - "GitHub MCP does not support OAuth. Log in by adding `bearer_token_env_var = CODEX_GITHUB_PAT` in the `mcp_servers.{server_name}` section of your config.toml" + "GitHub MCP does not support OAuth. Log in by adding a personal access token (https://github.com/settings/personal-access-tokens) to your environment and config.toml:\n[mcp_servers.{server_name}]\nbearer_token_env_var = CODEX_GITHUB_PERSONAL_ACCESS_TOKEN" ); assert_eq!(expected, display); @@ -3195,4 +3258,17 @@ mod tests { assert_eq!(expected, display); } + + #[test] + fn mcp_init_error_display_includes_startup_timeout_hint() { + let server_name = "slow"; + let err = anyhow::anyhow!("request timed out"); + + let display = mcp_init_error_display(server_name, None, &err); + + assert_eq!( + "MCP client for `slow` timed out after 10 seconds. Add or adjust `startup_timeout_sec` in your config.toml:\n[mcp_servers.slow]\nstartup_timeout_sec = XX", + display + ); + } } diff --git a/codex-rs/core/src/codex/compact.rs b/codex-rs/core/src/codex/compact.rs index 26f9f9eb9e..236b024139 100644 --- a/codex-rs/core/src/codex/compact.rs +++ b/codex-rs/core/src/codex/compact.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use super::Session; use super::TurnContext; +use super::filter_model_visible_history; use super::get_last_assistant_message_from_turn; use crate::Prompt; use crate::client_common::ResponseEvent; @@ -86,8 +87,9 @@ async fn run_compact_task_inner( loop { let turn_input = history.get_history(); + let prompt_input = filter_model_visible_history(turn_input.clone()); let prompt = Prompt { - input: turn_input.clone(), + input: prompt_input.clone(), ..Default::default() }; let attempt_result = drain_to_completed(&sess, turn_context.as_ref(), &prompt).await; @@ -109,7 +111,7 @@ async fn run_compact_task_inner( return; } Err(e @ CodexErr::ContextWindowExceeded) => { - if turn_input.len() > 1 { + if prompt_input.len() > 1 { // Trim from the beginning to preserve cache (prefix-based) and keep recent messages intact. error!( "Context window exceeded while compacting; removing oldest history item. Error: {e}" @@ -152,7 +154,13 @@ async fn run_compact_task_inner( let summary_text = get_last_assistant_message_from_turn(&history_snapshot).unwrap_or_default(); let user_messages = collect_user_messages(&history_snapshot); let initial_context = sess.build_initial_context(turn_context.as_ref()); - let new_history = build_compacted_history(initial_context, &user_messages, &summary_text); + let mut new_history = build_compacted_history(initial_context, &user_messages, &summary_text); + let ghost_snapshots: Vec = history_snapshot + .iter() + .filter(|item| matches!(item, ResponseItem::GhostSnapshot { .. })) + .cloned() + .collect(); + new_history.extend(ghost_snapshots); sess.replace_history(new_history).await; let rollout_item = RolloutItem::Compacted(CompactedItem { @@ -200,7 +208,20 @@ pub(crate) fn build_compacted_history( user_messages: &[String], summary_text: &str, ) -> Vec { - let mut history = initial_context; + build_compacted_history_with_limit( + initial_context, + user_messages, + summary_text, + COMPACT_USER_MESSAGE_MAX_TOKENS * 4, + ) +} + +fn build_compacted_history_with_limit( + mut history: Vec, + user_messages: &[String], + summary_text: &str, + max_bytes: usize, +) -> Vec { let mut user_messages_text = if user_messages.is_empty() { "(none)".to_string() } else { @@ -208,7 +229,6 @@ pub(crate) fn build_compacted_history( }; // Truncate the concatenated prior user messages so the bridge message // stays well under the context window (approx. 4 bytes/token). - let max_bytes = COMPACT_USER_MESSAGE_MAX_TOKENS * 4; if user_messages_text.len() > max_bytes { user_messages_text = truncate_middle(&user_messages_text, max_bytes).0; } @@ -361,11 +381,16 @@ mod tests { #[test] fn build_compacted_history_truncates_overlong_user_messages() { - // Prepare a very large prior user message so the aggregated - // `user_messages_text` exceeds the truncation threshold used by - // `build_compacted_history` (80k bytes). - let big = "X".repeat(200_000); - let history = build_compacted_history(Vec::new(), std::slice::from_ref(&big), "SUMMARY"); + // Use a small truncation limit so the test remains fast while still validating + // that oversized user content is truncated. + let max_bytes = 128; + let big = "X".repeat(max_bytes + 50); + let history = super::build_compacted_history_with_limit( + Vec::new(), + std::slice::from_ref(&big), + "SUMMARY", + max_bytes, + ); // Expect exactly one bridge message added to history (plus any initial context we provided, which is none). assert_eq!(history.len(), 1); diff --git a/codex-rs/core/src/codex_conversation.rs b/codex-rs/core/src/codex_conversation.rs index d3b00046fc..5bb9c97c5b 100644 --- a/codex-rs/core/src/codex_conversation.rs +++ b/codex-rs/core/src/codex_conversation.rs @@ -3,16 +3,21 @@ use crate::error::Result as CodexResult; use crate::protocol::Event; use crate::protocol::Op; use crate::protocol::Submission; +use std::path::PathBuf; pub struct CodexConversation { codex: Codex, + rollout_path: PathBuf, } /// Conduit for the bidirectional stream of messages that compose a conversation /// in Codex. impl CodexConversation { - pub(crate) fn new(codex: Codex) -> Self { - Self { codex } + pub(crate) fn new(codex: Codex, rollout_path: PathBuf) -> Self { + Self { + codex, + rollout_path, + } } pub async fn submit(&self, op: Op) -> CodexResult { @@ -27,4 +32,8 @@ impl CodexConversation { pub async fn next_event(&self) -> CodexResult { self.codex.next_event().await } + + pub fn rollout_path(&self) -> PathBuf { + self.rollout_path.clone() + } } diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 0af01f6857..c5406b1d27 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -223,6 +223,9 @@ pub struct Config { pub tools_web_search_request: bool, + /// When `true`, run a model-based assessment for commands denied by the sandbox. + pub experimental_sandbox_command_assessment: bool, + pub use_experimental_streamable_shell_tool: bool, /// If set to `true`, used only the experimental unified exec tool. @@ -958,6 +961,7 @@ pub struct ConfigToml { pub experimental_use_unified_exec_tool: Option, pub experimental_use_rmcp_client: Option, pub experimental_use_freeform_apply_patch: Option, + pub experimental_sandbox_command_assessment: Option, } impl From for UserSavedConfig { @@ -1023,9 +1027,11 @@ impl ConfigToml { fn derive_sandbox_policy( &self, sandbox_mode_override: Option, + profile_sandbox_mode: Option, resolved_cwd: &Path, ) -> SandboxPolicy { let resolved_sandbox_mode = sandbox_mode_override + .or(profile_sandbox_mode) .or(self.sandbox_mode) .or_else(|| { // if no sandbox_mode is set, but user has marked directory as trusted, use WorkspaceWrite @@ -1118,6 +1124,7 @@ pub struct ConfigOverrides { pub include_view_image_tool: Option, pub show_raw_agent_reasoning: Option, pub tools_web_search_request: Option, + pub experimental_sandbox_command_assessment: Option, /// Additional directories that should be treated as writable roots for this session. pub additional_writable_roots: Vec, } @@ -1147,6 +1154,7 @@ impl Config { include_view_image_tool: include_view_image_tool_override, show_raw_agent_reasoning, tools_web_search_request: override_tools_web_search_request, + experimental_sandbox_command_assessment: sandbox_command_assessment_override, additional_writable_roots, } = overrides; @@ -1172,6 +1180,7 @@ impl Config { include_apply_patch_tool: include_apply_patch_tool_override, include_view_image_tool: include_view_image_tool_override, web_search_request: override_tools_web_search_request, + experimental_sandbox_command_assessment: sandbox_command_assessment_override, }; let features = Features::from_config(&cfg, &config_profile, feature_overrides); @@ -1212,7 +1221,8 @@ impl Config { .get_active_project(&resolved_cwd) .unwrap_or(ProjectConfig { trust_level: None }); - let mut sandbox_policy = cfg.derive_sandbox_policy(sandbox_mode, &resolved_cwd); + let mut sandbox_policy = + cfg.derive_sandbox_policy(sandbox_mode, config_profile.sandbox_mode, &resolved_cwd); if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = &mut sandbox_policy { for path in additional_writable_roots { if !writable_roots.iter().any(|existing| existing == &path) { @@ -1235,8 +1245,8 @@ impl Config { .is_some() || config_profile.approval_policy.is_some() || cfg.approval_policy.is_some() - // TODO(#3034): profile.sandbox_mode is not implemented || sandbox_mode.is_some() + || config_profile.sandbox_mode.is_some() || cfg.sandbox_mode.is_some(); let mut model_providers = built_in_model_providers(); @@ -1269,6 +1279,8 @@ impl Config { let use_experimental_streamable_shell_tool = features.enabled(Feature::StreamableShell); let use_experimental_unified_exec_tool = features.enabled(Feature::UnifiedExec); let use_experimental_use_rmcp_client = features.enabled(Feature::RmcpClient); + let experimental_sandbox_command_assessment = + features.enabled(Feature::SandboxCommandAssessment); let forced_chatgpt_workspace_id = cfg.forced_chatgpt_workspace_id.as_ref().and_then(|value| { @@ -1390,6 +1402,7 @@ impl Config { forced_login_method, include_apply_patch_tool: include_apply_patch_tool_flag, tools_web_search_request, + experimental_sandbox_command_assessment, use_experimental_streamable_shell_tool, use_experimental_unified_exec_tool, use_experimental_use_rmcp_client, @@ -1593,8 +1606,11 @@ network_access = false # This should be ignored. let sandbox_mode_override = None; assert_eq!( SandboxPolicy::DangerFullAccess, - sandbox_full_access_cfg - .derive_sandbox_policy(sandbox_mode_override, &PathBuf::from("/tmp/test")) + sandbox_full_access_cfg.derive_sandbox_policy( + sandbox_mode_override, + None, + &PathBuf::from("/tmp/test") + ) ); let sandbox_read_only = r#" @@ -1609,8 +1625,11 @@ network_access = true # This should be ignored. let sandbox_mode_override = None; assert_eq!( SandboxPolicy::ReadOnly, - sandbox_read_only_cfg - .derive_sandbox_policy(sandbox_mode_override, &PathBuf::from("/tmp/test")) + sandbox_read_only_cfg.derive_sandbox_policy( + sandbox_mode_override, + None, + &PathBuf::from("/tmp/test") + ) ); let sandbox_workspace_write = r#" @@ -1634,8 +1653,11 @@ exclude_slash_tmp = true exclude_tmpdir_env_var: true, exclude_slash_tmp: true, }, - sandbox_workspace_write_cfg - .derive_sandbox_policy(sandbox_mode_override, &PathBuf::from("/tmp/test")) + sandbox_workspace_write_cfg.derive_sandbox_policy( + sandbox_mode_override, + None, + &PathBuf::from("/tmp/test") + ) ); let sandbox_workspace_write = r#" @@ -1662,8 +1684,11 @@ trust_level = "trusted" exclude_tmpdir_env_var: true, exclude_slash_tmp: true, }, - sandbox_workspace_write_cfg - .derive_sandbox_policy(sandbox_mode_override, &PathBuf::from("/tmp/test")) + sandbox_workspace_write_cfg.derive_sandbox_policy( + sandbox_mode_override, + None, + &PathBuf::from("/tmp/test") + ) ); } @@ -1755,6 +1780,75 @@ trust_level = "trusted" 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.sandbox_policy, + SandboxPolicy::DangerFullAccess + )); + assert!(config.did_user_set_custom_approval_policy_or_sandbox_mode); + + 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(), + )?; + + assert!(matches!( + config.sandbox_policy, + SandboxPolicy::WorkspaceWrite { .. } + )); + + Ok(()) + } + #[test] fn feature_table_overrides_legacy_flags() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -2873,6 +2967,7 @@ model_verbosity = "high" forced_login_method: None, include_apply_patch_tool: false, tools_web_search_request: false, + experimental_sandbox_command_assessment: false, use_experimental_streamable_shell_tool: false, use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, @@ -2941,6 +3036,7 @@ model_verbosity = "high" forced_login_method: None, include_apply_patch_tool: false, tools_web_search_request: false, + experimental_sandbox_command_assessment: false, use_experimental_streamable_shell_tool: false, use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, @@ -3024,6 +3120,7 @@ model_verbosity = "high" forced_login_method: None, include_apply_patch_tool: false, tools_web_search_request: false, + experimental_sandbox_command_assessment: false, use_experimental_streamable_shell_tool: false, use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, @@ -3093,6 +3190,7 @@ model_verbosity = "high" forced_login_method: None, include_apply_patch_tool: false, tools_web_search_request: false, + experimental_sandbox_command_assessment: false, use_experimental_streamable_shell_tool: false, use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, diff --git a/codex-rs/core/src/config_profile.rs b/codex-rs/core/src/config_profile.rs index 1986d42ffa..7b7cafedbc 100644 --- a/codex-rs/core/src/config_profile.rs +++ b/codex-rs/core/src/config_profile.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use crate::protocol::AskForApproval; use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::Verbosity; /// Collection of common configuration options that a user can define as a unit @@ -15,6 +16,7 @@ pub struct ConfigProfile { /// [`ModelProviderInfo`] to use. pub model_provider: Option, pub approval_policy: Option, + pub sandbox_mode: Option, pub model_reasoning_effort: Option, pub model_reasoning_summary: Option, pub model_verbosity: Option, @@ -26,6 +28,7 @@ pub struct ConfigProfile { pub experimental_use_exec_command_tool: Option, pub experimental_use_rmcp_client: Option, pub experimental_use_freeform_apply_patch: Option, + pub experimental_sandbox_command_assessment: Option, pub tools_web_search: Option, pub tools_view_image: Option, /// Optional feature toggles scoped to this profile. diff --git a/codex-rs/core/src/conversation_history.rs b/codex-rs/core/src/conversation_history.rs index 93234a4936..08fa8cebed 100644 --- a/codex-rs/core/src/conversation_history.rs +++ b/codex-rs/core/src/conversation_history.rs @@ -1,5 +1,8 @@ use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::TokenUsage; +use codex_protocol::protocol::TokenUsageInfo; +use std::ops::Deref; use tracing::error; /// Transcript of conversation history @@ -7,11 +10,28 @@ use tracing::error; pub(crate) struct ConversationHistory { /// The oldest items are at the beginning of the vector. items: Vec, + token_info: Option, } impl ConversationHistory { pub(crate) fn new() -> Self { - Self { items: Vec::new() } + Self { + items: Vec::new(), + token_info: TokenUsageInfo::new_or_append(&None, &None, None), + } + } + + pub(crate) fn token_info(&self) -> Option { + self.token_info.clone() + } + + pub(crate) fn set_token_usage_full(&mut self, context_window: i64) { + match &mut self.token_info { + Some(info) => info.fill_to_context_window(context_window), + None => { + self.token_info = Some(TokenUsageInfo::full_context_window(context_window)); + } + } } /// `items` is ordered from oldest to newest. @@ -21,7 +41,9 @@ impl ConversationHistory { I::Item: std::ops::Deref, { for item in items { - if !is_api_message(&item) { + let item_ref = item.deref(); + let is_ghost_snapshot = matches!(item_ref, ResponseItem::GhostSnapshot { .. }); + if !is_api_message(item_ref) && !is_ghost_snapshot { continue; } @@ -146,6 +168,7 @@ impl ConversationHistory { | ResponseItem::WebSearchCall { .. } | ResponseItem::FunctionCallOutput { .. } | ResponseItem::CustomToolCallOutput { .. } + | ResponseItem::GhostSnapshot { .. } | ResponseItem::Other | ResponseItem::Message { .. } => { // nothing to do for these variants @@ -212,6 +235,7 @@ impl ConversationHistory { | ResponseItem::LocalShellCall { .. } | ResponseItem::Reasoning { .. } | ResponseItem::WebSearchCall { .. } + | ResponseItem::GhostSnapshot { .. } | ResponseItem::Other | ResponseItem::Message { .. } => { // nothing to do for these variants @@ -301,6 +325,18 @@ impl ConversationHistory { self.items.remove(pos); } } + + pub(crate) fn update_token_info( + &mut self, + usage: &TokenUsage, + model_context_window: Option, + ) { + self.token_info = TokenUsageInfo::new_or_append( + &self.token_info, + &Some(usage.clone()), + model_context_window, + ); + } } #[inline] @@ -324,6 +360,7 @@ fn is_api_message(message: &ResponseItem) -> bool { | ResponseItem::LocalShellCall { .. } | ResponseItem::Reasoning { .. } | ResponseItem::WebSearchCall { .. } => true, + ResponseItem::GhostSnapshot { .. } => false, ResponseItem::Other => false, } } diff --git a/codex-rs/core/src/conversation_manager.rs b/codex-rs/core/src/conversation_manager.rs index a30038f2eb..b911526f68 100644 --- a/codex-rs/core/src/conversation_manager.rs +++ b/codex-rs/core/src/conversation_manager.rs @@ -98,7 +98,10 @@ impl ConversationManager { } }; - let conversation = Arc::new(CodexConversation::new(codex)); + let conversation = Arc::new(CodexConversation::new( + codex, + session_configured.rollout_path.clone(), + )); self.conversations .write() .await diff --git a/codex-rs/core/src/default_client.rs b/codex-rs/core/src/default_client.rs index 42690e04cc..8e4635460c 100644 --- a/codex-rs/core/src/default_client.rs +++ b/codex-rs/core/src/default_client.rs @@ -1,5 +1,13 @@ use crate::spawn::CODEX_SANDBOX_ENV_VAR; +use http::Error as HttpError; +use reqwest::IntoUrl; +use reqwest::Method; +use reqwest::Response; +use reqwest::header::HeaderName; use reqwest::header::HeaderValue; +use serde::Serialize; +use std::collections::HashMap; +use std::fmt::Display; use std::sync::LazyLock; use std::sync::Mutex; use std::sync::OnceLock; @@ -22,6 +30,130 @@ use std::sync::OnceLock; pub static USER_AGENT_SUFFIX: LazyLock>> = LazyLock::new(|| Mutex::new(None)); pub const DEFAULT_ORIGINATOR: &str = "codex_cli_rs"; pub const CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR: &str = "CODEX_INTERNAL_ORIGINATOR_OVERRIDE"; + +#[derive(Clone, Debug)] +pub struct CodexHttpClient { + inner: reqwest::Client, +} + +impl CodexHttpClient { + fn new(inner: reqwest::Client) -> Self { + Self { inner } + } + + pub fn get(&self, url: U) -> CodexRequestBuilder + where + U: IntoUrl, + { + self.request(Method::GET, url) + } + + pub fn post(&self, url: U) -> CodexRequestBuilder + where + U: IntoUrl, + { + self.request(Method::POST, url) + } + + pub fn request(&self, method: Method, url: U) -> CodexRequestBuilder + where + U: IntoUrl, + { + let url_str = url.as_str().to_string(); + CodexRequestBuilder::new(self.inner.request(method.clone(), url), method, url_str) + } +} + +#[must_use = "requests are not sent unless `send` is awaited"] +#[derive(Debug)] +pub struct CodexRequestBuilder { + builder: reqwest::RequestBuilder, + method: Method, + url: String, +} + +impl CodexRequestBuilder { + fn new(builder: reqwest::RequestBuilder, method: Method, url: String) -> Self { + Self { + builder, + method, + url, + } + } + + fn map(self, f: impl FnOnce(reqwest::RequestBuilder) -> reqwest::RequestBuilder) -> Self { + Self { + builder: f(self.builder), + method: self.method, + url: self.url, + } + } + + pub fn header(self, key: K, value: V) -> Self + where + HeaderName: TryFrom, + >::Error: Into, + HeaderValue: TryFrom, + >::Error: Into, + { + self.map(|builder| builder.header(key, value)) + } + + pub fn bearer_auth(self, token: T) -> Self + where + T: Display, + { + self.map(|builder| builder.bearer_auth(token)) + } + + pub fn json(self, value: &T) -> Self + where + T: ?Sized + Serialize, + { + self.map(|builder| builder.json(value)) + } + + pub async fn send(self) -> Result { + match self.builder.send().await { + Ok(response) => { + let request_ids = Self::extract_request_ids(&response); + tracing::debug!( + method = %self.method, + url = %self.url, + status = %response.status(), + request_ids = ?request_ids, + version = ?response.version(), + "Request completed" + ); + + Ok(response) + } + Err(error) => { + let status = error.status(); + tracing::debug!( + method = %self.method, + url = %self.url, + status = status.map(|s| s.as_u16()), + error = %error, + "Request failed" + ); + Err(error) + } + } + } + + fn extract_request_ids(response: &Response) -> HashMap { + ["cf-ray", "x-request-id", "x-oai-request-id"] + .iter() + .filter_map(|&name| { + let header_name = HeaderName::from_static(name); + let value = response.headers().get(header_name)?; + let value = value.to_str().ok()?.to_owned(); + Some((name.to_owned(), value)) + }) + .collect() + } +} #[derive(Debug, Clone)] pub struct Originator { pub value: String, @@ -124,8 +256,8 @@ fn sanitize_user_agent(candidate: String, fallback: &str) -> String { } } -/// Create a reqwest client with default `originator` and `User-Agent` headers set. -pub fn create_client() -> reqwest::Client { +/// Create an HTTP client with default `originator` and `User-Agent` headers set. +pub fn create_client() -> CodexHttpClient { use reqwest::header::HeaderMap; let mut headers = HeaderMap::new(); @@ -140,7 +272,8 @@ pub fn create_client() -> reqwest::Client { builder = builder.no_proxy(); } - builder.build().unwrap_or_else(|_| reqwest::Client::new()) + let inner = builder.build().unwrap_or_else(|_| reqwest::Client::new()); + CodexHttpClient::new(inner) } fn is_sandboxed() -> bool { diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index 459cc1752f..0901e426ad 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -1,3 +1,4 @@ +use crate::codex::ProcessedResponseItem; use crate::exec::ExecToolCallOutput; use crate::token_data::KnownPlan; use crate::token_data::PlanType; @@ -53,8 +54,11 @@ pub enum SandboxErr { #[derive(Error, Debug)] pub enum CodexErr { - #[error("turn aborted")] - TurnAborted, + // todo(aibrahim): git rid of this error carrying the dangling artifacts + #[error("turn aborted. Something went wrong? Hit `/feedback` to report the issue.")] + TurnAborted { + dangling_artifacts: Vec, + }, /// Returned by ResponsesClient when the SSE stream disconnects or errors out **after** the HTTP /// handshake has succeeded but **before** it finished emitting `response.completed`. @@ -87,7 +91,7 @@ pub enum CodexErr { /// Returned by run_command_stream when the user pressed Ctrl‑C (SIGINT). Session uses this to /// surface a polite FunctionCallOutput back to the model instead of crashing the CLI. - #[error("interrupted (Ctrl-C)")] + #[error("interrupted (Ctrl-C). Something went wrong? Hit `/feedback` to report the issue.")] Interrupted, /// Unexpected HTTP status code. @@ -158,7 +162,9 @@ pub enum CodexErr { impl From for CodexErr { fn from(_: CancelErr) -> Self { - CodexErr::TurnAborted + CodexErr::TurnAborted { + dangling_artifacts: Vec::new(), + } } } diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index ead4604d54..c10305b800 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -39,6 +39,10 @@ pub enum Feature { ViewImageTool, /// Allow the model to request web searches. WebSearchRequest, + /// Enable the model-based risk assessments for sandboxed commands. + SandboxCommandAssessment, + /// Create a ghost commit at each turn. + GhostCommit, } impl Feature { @@ -73,6 +77,7 @@ pub struct FeatureOverrides { pub include_apply_patch_tool: Option, pub include_view_image_tool: Option, pub web_search_request: Option, + pub experimental_sandbox_command_assessment: Option, } impl FeatureOverrides { @@ -137,6 +142,7 @@ impl Features { let mut features = Features::with_defaults(); let base_legacy = LegacyFeatureToggles { + experimental_sandbox_command_assessment: cfg.experimental_sandbox_command_assessment, experimental_use_freeform_apply_patch: cfg.experimental_use_freeform_apply_patch, experimental_use_exec_command_tool: cfg.experimental_use_exec_command_tool, experimental_use_unified_exec_tool: cfg.experimental_use_unified_exec_tool, @@ -154,6 +160,8 @@ impl Features { let profile_legacy = LegacyFeatureToggles { include_apply_patch_tool: config_profile.include_apply_patch_tool, include_view_image_tool: config_profile.include_view_image_tool, + experimental_sandbox_command_assessment: config_profile + .experimental_sandbox_command_assessment, experimental_use_freeform_apply_patch: config_profile .experimental_use_freeform_apply_patch, experimental_use_exec_command_tool: config_profile.experimental_use_exec_command_tool, @@ -236,4 +244,16 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: false, }, + FeatureSpec { + id: Feature::SandboxCommandAssessment, + key: "experimental_sandbox_command_assessment", + stage: Stage::Experimental, + default_enabled: false, + }, + FeatureSpec { + id: Feature::GhostCommit, + key: "ghost_commit", + stage: Stage::Experimental, + default_enabled: false, + }, ]; diff --git a/codex-rs/core/src/features/legacy.rs b/codex-rs/core/src/features/legacy.rs index 22d8442ace..54f6a2d549 100644 --- a/codex-rs/core/src/features/legacy.rs +++ b/codex-rs/core/src/features/legacy.rs @@ -9,6 +9,10 @@ struct Alias { } const ALIASES: &[Alias] = &[ + Alias { + legacy_key: "experimental_sandbox_command_assessment", + feature: Feature::SandboxCommandAssessment, + }, Alias { legacy_key: "experimental_use_unified_exec_tool", feature: Feature::UnifiedExec, @@ -53,6 +57,7 @@ pub(crate) fn feature_for_key(key: &str) -> Option { pub struct LegacyFeatureToggles { pub include_apply_patch_tool: Option, pub include_view_image_tool: Option, + pub experimental_sandbox_command_assessment: Option, pub experimental_use_freeform_apply_patch: Option, pub experimental_use_exec_command_tool: Option, pub experimental_use_unified_exec_tool: Option, @@ -69,6 +74,12 @@ impl LegacyFeatureToggles { self.include_apply_patch_tool, "include_apply_patch_tool", ); + set_if_some( + features, + Feature::SandboxCommandAssessment, + self.experimental_sandbox_command_assessment, + "experimental_sandbox_command_assessment", + ); set_if_some( features, Feature::ApplyPatchFreeform, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 5e5b4e44a9..34b6df4a5a 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -36,6 +36,7 @@ mod mcp_tool_call; mod message_history; mod model_provider_info; pub mod parse_command; +mod response_processing; pub mod sandboxing; pub mod token_data; mod truncate; diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 8cc2f48b72..ebe17d2c25 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -49,7 +49,7 @@ const MCP_TOOL_NAME_DELIMITER: &str = "__"; const MAX_TOOL_NAME_LENGTH: usize = 64; /// Default timeout for initializing MCP server & initially listing tools. -const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(10); +pub const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(10); /// Default timeout for individual tool calls. const DEFAULT_TOOL_TIMEOUT: Duration = Duration::from_secs(60); diff --git a/codex-rs/core/src/model_family.rs b/codex-rs/core/src/model_family.rs index 37c3bd1491..736e9604fb 100644 --- a/codex-rs/core/src/model_family.rs +++ b/codex-rs/core/src/model_family.rs @@ -131,7 +131,7 @@ pub fn find_family_for_model(slug: &str) -> Option { ) // Internal models. - } else if slug.starts_with("codex-") { + } else if slug.starts_with("codex-exp-") { model_family!( slug, slug, supports_reasoning_summaries: true, @@ -147,7 +147,7 @@ pub fn find_family_for_model(slug: &str) -> Option { ) // Production models. - } else if slug.starts_with("gpt-5-codex") { + } else if slug.starts_with("gpt-5-codex") || slug.starts_with("codex-") { model_family!( slug, slug, supports_reasoning_summaries: true, diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs index 429a260653..8dc252aa7c 100644 --- a/codex-rs/core/src/model_provider_info.rs +++ b/codex-rs/core/src/model_provider_info.rs @@ -6,6 +6,8 @@ //! key. These override or extend the defaults at runtime. use crate::CodexAuth; +use crate::default_client::CodexHttpClient; +use crate::default_client::CodexRequestBuilder; use codex_app_server_protocol::AuthMode; use serde::Deserialize; use serde::Serialize; @@ -95,7 +97,7 @@ pub struct ModelProviderInfo { impl ModelProviderInfo { /// Construct a `POST` RequestBuilder for the given URL using the provided - /// reqwest Client applying: + /// [`CodexHttpClient`] applying: /// • provider-specific headers (static + env based) /// • Bearer auth header when an API key is available. /// • Auth token for OAuth. @@ -104,9 +106,9 @@ impl ModelProviderInfo { /// one produced by [`ModelProviderInfo::api_key`]. pub async fn create_request_builder<'a>( &'a self, - client: &'a reqwest::Client, + client: &'a CodexHttpClient, auth: &Option, - ) -> crate::error::Result { + ) -> crate::error::Result { let effective_auth = if let Some(secret_key) = &self.experimental_bearer_token { Some(CodexAuth::from_api_key(secret_key)) } else { @@ -187,9 +189,9 @@ impl ModelProviderInfo { } /// Apply provider-specific HTTP headers (both static and environment-based) - /// onto an existing `reqwest::RequestBuilder` and return the updated + /// onto an existing [`CodexRequestBuilder`] and return the updated /// builder. - fn apply_http_headers(&self, mut builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + fn apply_http_headers(&self, mut builder: CodexRequestBuilder) -> CodexRequestBuilder { if let Some(extra) = &self.http_headers { for (k, v) in extra { builder = builder.header(k, v); diff --git a/codex-rs/core/src/response_processing.rs b/codex-rs/core/src/response_processing.rs new file mode 100644 index 0000000000..e18fdd45c8 --- /dev/null +++ b/codex-rs/core/src/response_processing.rs @@ -0,0 +1,114 @@ +use crate::codex::Session; +use crate::codex::TurnContext; +use crate::conversation_history::ConversationHistory; +use codex_protocol::models::FunctionCallOutputPayload; +use codex_protocol::models::ResponseInputItem; +use codex_protocol::models::ResponseItem; +use tracing::warn; + +/// Process streamed `ResponseItem`s from the model into the pair of: +/// - items we should record in conversation history; and +/// - `ResponseInputItem`s to send back to the model on the next turn. +pub(crate) async fn process_items( + processed_items: Vec, + is_review_mode: bool, + review_thread_history: &mut ConversationHistory, + sess: &Session, + turn_context: &TurnContext, +) -> (Vec, Vec) { + let mut items_to_record_in_conversation_history = Vec::::new(); + let mut responses = Vec::::new(); + for processed_response_item in processed_items { + let crate::codex::ProcessedResponseItem { item, response } = processed_response_item; + match (&item, &response) { + (ResponseItem::Message { role, .. }, None) if role == "assistant" => { + // If the model returned a message, we need to record it. + items_to_record_in_conversation_history.push(item); + } + ( + ResponseItem::LocalShellCall { .. }, + Some(ResponseInputItem::FunctionCallOutput { call_id, output }), + ) => { + items_to_record_in_conversation_history.push(item); + items_to_record_in_conversation_history.push(ResponseItem::FunctionCallOutput { + call_id: call_id.clone(), + output: output.clone(), + }); + } + ( + ResponseItem::FunctionCall { .. }, + Some(ResponseInputItem::FunctionCallOutput { call_id, output }), + ) => { + items_to_record_in_conversation_history.push(item); + items_to_record_in_conversation_history.push(ResponseItem::FunctionCallOutput { + call_id: call_id.clone(), + output: output.clone(), + }); + } + ( + ResponseItem::CustomToolCall { .. }, + Some(ResponseInputItem::CustomToolCallOutput { call_id, output }), + ) => { + items_to_record_in_conversation_history.push(item); + items_to_record_in_conversation_history.push(ResponseItem::CustomToolCallOutput { + call_id: call_id.clone(), + output: output.clone(), + }); + } + ( + ResponseItem::FunctionCall { .. }, + Some(ResponseInputItem::McpToolCallOutput { call_id, result }), + ) => { + items_to_record_in_conversation_history.push(item); + let output = match result { + Ok(call_tool_result) => { + crate::codex::convert_call_tool_result_to_function_call_output_payload( + call_tool_result, + ) + } + Err(err) => FunctionCallOutputPayload { + content: err.clone(), + success: Some(false), + }, + }; + items_to_record_in_conversation_history.push(ResponseItem::FunctionCallOutput { + call_id: call_id.clone(), + output, + }); + } + ( + ResponseItem::Reasoning { + id, + summary, + content, + encrypted_content, + }, + None, + ) => { + items_to_record_in_conversation_history.push(ResponseItem::Reasoning { + id: id.clone(), + summary: summary.clone(), + content: content.clone(), + encrypted_content: encrypted_content.clone(), + }); + } + _ => { + warn!("Unexpected response item: {item:?} with response: {response:?}"); + } + }; + if let Some(response) = response { + responses.push(response); + } + } + + // Only attempt to take the lock if there is something to record. + if !items_to_record_in_conversation_history.is_empty() { + if is_review_mode { + review_thread_history.record_items(items_to_record_in_conversation_history.iter()); + } else { + sess.record_conversation_items(turn_context, &items_to_record_in_conversation_history) + .await; + } + } + (responses, items_to_record_in_conversation_history) +} diff --git a/codex-rs/core/src/rollout/list.rs b/codex-rs/core/src/rollout/list.rs index b27b3382e3..4a3fec7934 100644 --- a/codex-rs/core/src/rollout/list.rs +++ b/codex-rs/core/src/rollout/list.rs @@ -1,12 +1,11 @@ use std::cmp::Reverse; use std::io::{self}; +use std::num::NonZero; use std::path::Path; use std::path::PathBuf; - -use codex_file_search as file_search; -use std::num::NonZero; use std::sync::Arc; use std::sync::atomic::AtomicBool; + use time::OffsetDateTime; use time::PrimitiveDateTime; use time::format_description::FormatItem; @@ -15,6 +14,7 @@ use uuid::Uuid; use super::SESSIONS_SUBDIR; use crate::protocol::EventMsg; +use codex_file_search as file_search; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_protocol::protocol::SessionSource; @@ -54,6 +54,7 @@ struct HeadTailSummary { saw_session_meta: bool, saw_user_event: bool, source: Option, + model_provider: Option, created_at: Option, updated_at: Option, } @@ -109,6 +110,8 @@ pub(crate) async fn get_conversations( page_size: usize, cursor: Option<&Cursor>, allowed_sources: &[SessionSource], + model_providers: Option<&[String]>, + default_provider: &str, ) -> io::Result { let mut root = codex_home.to_path_buf(); root.push(SESSIONS_SUBDIR); @@ -124,8 +127,17 @@ pub(crate) async fn get_conversations( let anchor = cursor.cloned(); - let result = - traverse_directories_for_paths(root.clone(), page_size, anchor, allowed_sources).await?; + let provider_matcher = + model_providers.and_then(|filters| ProviderMatcher::new(filters, default_provider)); + + let result = traverse_directories_for_paths( + root.clone(), + page_size, + anchor, + allowed_sources, + provider_matcher.as_ref(), + ) + .await?; Ok(result) } @@ -145,6 +157,7 @@ async fn traverse_directories_for_paths( page_size: usize, anchor: Option, allowed_sources: &[SessionSource], + provider_matcher: Option<&ProviderMatcher<'_>>, ) -> io::Result { let mut items: Vec = Vec::with_capacity(page_size); let mut scanned_files = 0usize; @@ -153,6 +166,7 @@ async fn traverse_directories_for_paths( Some(c) => (c.ts, c.id), None => (OffsetDateTime::UNIX_EPOCH, Uuid::nil()), }; + let mut more_matches_available = false; let year_dirs = collect_dirs_desc(&root, |s| s.parse::().ok()).await?; @@ -184,6 +198,7 @@ async fn traverse_directories_for_paths( for (ts, sid, _name_str, path) in day_files.into_iter() { scanned_files += 1; if scanned_files >= MAX_SCAN_FILES && items.len() >= page_size { + more_matches_available = true; break 'outer; } if !anchor_passed { @@ -194,6 +209,7 @@ async fn traverse_directories_for_paths( } } if items.len() == page_size { + more_matches_available = true; break 'outer; } // Read head and simultaneously detect message events within the same @@ -208,6 +224,11 @@ async fn traverse_directories_for_paths( { continue; } + if let Some(matcher) = provider_matcher + && !matcher.matches(summary.model_provider.as_deref()) + { + continue; + } // Apply filters: must have session meta and at least one user message event if summary.saw_session_meta && summary.saw_user_event { let HeadTailSummary { @@ -231,12 +252,21 @@ async fn traverse_directories_for_paths( } } - let next = build_next_cursor(&items); + let reached_scan_cap = scanned_files >= MAX_SCAN_FILES; + if reached_scan_cap && !items.is_empty() { + more_matches_available = true; + } + + let next = if more_matches_available { + build_next_cursor(&items) + } else { + None + }; Ok(ConversationsPage { items, next_cursor: next, num_scanned_files: scanned_files, - reached_scan_cap: scanned_files >= MAX_SCAN_FILES, + reached_scan_cap, }) } @@ -328,6 +358,32 @@ fn parse_timestamp_uuid_from_filename(name: &str) -> Option<(OffsetDateTime, Uui Some((ts, uuid)) } +struct ProviderMatcher<'a> { + filters: &'a [String], + matches_default_provider: bool, +} + +impl<'a> ProviderMatcher<'a> { + fn new(filters: &'a [String], default_provider: &'a str) -> Option { + if filters.is_empty() { + return None; + } + + let matches_default_provider = filters.iter().any(|provider| provider == default_provider); + Some(Self { + filters, + matches_default_provider, + }) + } + + fn matches(&self, session_provider: Option<&str>) -> bool { + match session_provider { + Some(provider) => self.filters.iter().any(|candidate| candidate == provider), + None => self.matches_default_provider, + } + } +} + async fn read_head_and_tail( path: &Path, head_limit: usize, @@ -354,6 +410,7 @@ async fn read_head_and_tail( match rollout_line.item { RolloutItem::SessionMeta(session_meta_line) => { summary.source = Some(session_meta_line.meta.source); + summary.model_provider = session_meta_line.meta.model_provider.clone(); summary.created_at = summary .created_at .clone() @@ -515,6 +572,7 @@ pub async fn find_conversation_path_by_id_str( threads, cancel, compute_indices, + false, ) .map_err(|e| io::Error::other(format!("file search failed: {e}")))?; diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index fdf1f5ccb2..f1b786055e 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -26,7 +26,8 @@ pub(crate) fn should_persist_response_item(item: &ResponseItem) -> bool { | ResponseItem::FunctionCallOutput { .. } | ResponseItem::CustomToolCall { .. } | ResponseItem::CustomToolCallOutput { .. } - | ResponseItem::WebSearchCall { .. } => true, + | ResponseItem::WebSearchCall { .. } + | ResponseItem::GhostSnapshot { .. } => true, ResponseItem::Other => false, } } @@ -42,6 +43,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::TokenCount(_) | EventMsg::EnteredReviewMode(_) | EventMsg::ExitedReviewMode(_) + | EventMsg::UndoCompleted(_) | EventMsg::TurnAborted(_) => true, EventMsg::Error(_) | EventMsg::TaskStarted(_) @@ -50,6 +52,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::AgentReasoningDelta(_) | EventMsg::AgentReasoningRawContentDelta(_) | EventMsg::AgentReasoningSectionBreak(_) + | EventMsg::RawResponseItem(_) | EventMsg::SessionConfigured(_) | EventMsg::McpToolCallBegin(_) | EventMsg::McpToolCallEnd(_) @@ -66,12 +69,12 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::PatchApplyEnd(_) | EventMsg::TurnDiff(_) | EventMsg::GetHistoryEntryResponse(_) + | EventMsg::UndoStarted(_) | EventMsg::McpListToolsResponse(_) | EventMsg::ListCustomPromptsResponse(_) | EventMsg::PlanUpdate(_) | EventMsg::ShutdownComplete | EventMsg::ViewImageToolCall(_) - | EventMsg::ConversationPath(_) | EventMsg::ItemStarted(_) | EventMsg::ItemCompleted(_) => false, } diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index 95f5d479b8..d575da9652 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -97,8 +97,18 @@ impl RolloutRecorder { page_size: usize, cursor: Option<&Cursor>, allowed_sources: &[SessionSource], + model_providers: Option<&[String]>, + default_provider: &str, ) -> std::io::Result { - get_conversations(codex_home, page_size, cursor, allowed_sources).await + get_conversations( + codex_home, + page_size, + cursor, + allowed_sources, + model_providers, + default_provider, + ) + .await } /// Attempt to create a new [`RolloutRecorder`]. If the sessions directory @@ -137,6 +147,7 @@ impl RolloutRecorder { cli_version: env!("CARGO_PKG_VERSION").to_string(), instructions, source, + model_provider: Some(config.model_provider_id.clone()), }), ) } @@ -267,10 +278,6 @@ impl RolloutRecorder { })) } - pub(crate) fn get_rollout_path(&self) -> PathBuf { - self.rollout_path.clone() - } - pub async fn shutdown(&self) -> std::io::Result<()> { let (tx_done, rx_done) = oneshot::channel(); match self.tx.send(RolloutCmd::Shutdown { ack: tx_done }).await { diff --git a/codex-rs/core/src/rollout/tests.rs b/codex-rs/core/src/rollout/tests.rs index 617e5189c2..a7bc9f8c8f 100644 --- a/codex-rs/core/src/rollout/tests.rs +++ b/codex-rs/core/src/rollout/tests.rs @@ -32,6 +32,14 @@ use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::UserMessageEvent; const NO_SOURCE_FILTER: &[SessionSource] = &[]; +const TEST_PROVIDER: &str = "test-provider"; + +fn provider_vec(providers: &[&str]) -> Vec { + providers + .iter() + .map(std::string::ToString::to_string) + .collect() +} fn write_session_file( root: &Path, @@ -39,6 +47,24 @@ fn write_session_file( uuid: Uuid, num_records: usize, source: Option, +) -> std::io::Result<(OffsetDateTime, Uuid)> { + write_session_file_with_provider( + root, + ts_str, + uuid, + num_records, + source, + Some("test-provider"), + ) +} + +fn write_session_file_with_provider( + root: &Path, + ts_str: &str, + uuid: Uuid, + num_records: usize, + source: Option, + model_provider: Option<&str>, ) -> std::io::Result<(OffsetDateTime, Uuid)> { let format: &[FormatItem] = format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"); @@ -68,6 +94,9 @@ fn write_session_file( if let Some(source) = source { payload["source"] = serde_json::to_value(source).unwrap(); } + if let Some(provider) = model_provider { + payload["model_provider"] = serde_json::Value::String(provider.to_string()); + } let meta = serde_json::json!({ "timestamp": ts_str, @@ -134,9 +163,17 @@ async fn test_list_conversations_latest_first() { ) .unwrap(); - let page = get_conversations(home, 10, None, INTERACTIVE_SESSION_SOURCES) - .await - .unwrap(); + let provider_filter = provider_vec(&[TEST_PROVIDER]); + let page = get_conversations( + home, + 10, + None, + INTERACTIVE_SESSION_SOURCES, + Some(provider_filter.as_slice()), + TEST_PROVIDER, + ) + .await + .unwrap(); // Build expected objects let p1 = home @@ -166,6 +203,7 @@ async fn test_list_conversations_latest_first() { "originator": "test_originator", "cli_version": "test_version", "source": "vscode", + "model_provider": "test-provider", })]; let head_2 = vec![serde_json::json!({ "id": u2, @@ -175,6 +213,7 @@ async fn test_list_conversations_latest_first() { "originator": "test_originator", "cli_version": "test_version", "source": "vscode", + "model_provider": "test-provider", })]; let head_1 = vec![serde_json::json!({ "id": u1, @@ -184,11 +223,9 @@ async fn test_list_conversations_latest_first() { "originator": "test_originator", "cli_version": "test_version", "source": "vscode", + "model_provider": "test-provider", })]; - let expected_cursor: Cursor = - serde_json::from_str(&format!("\"2025-01-01T12-00-00|{u1}\"")).unwrap(); - let expected = ConversationsPage { items: vec![ ConversationItem { @@ -213,7 +250,7 @@ async fn test_list_conversations_latest_first() { updated_at: Some("2025-01-01T12-00-00".into()), }, ], - next_cursor: Some(expected_cursor), + next_cursor: None, num_scanned_files: 3, reached_scan_cap: false, }; @@ -275,9 +312,17 @@ async fn test_pagination_cursor() { ) .unwrap(); - let page1 = get_conversations(home, 2, None, INTERACTIVE_SESSION_SOURCES) - .await - .unwrap(); + let provider_filter = provider_vec(&[TEST_PROVIDER]); + let page1 = get_conversations( + home, + 2, + None, + INTERACTIVE_SESSION_SOURCES, + Some(provider_filter.as_slice()), + TEST_PROVIDER, + ) + .await + .unwrap(); let p5 = home .join("sessions") .join("2025") @@ -298,6 +343,7 @@ async fn test_pagination_cursor() { "originator": "test_originator", "cli_version": "test_version", "source": "vscode", + "model_provider": "test-provider", })]; let head_4 = vec![serde_json::json!({ "id": u4, @@ -307,6 +353,7 @@ async fn test_pagination_cursor() { "originator": "test_originator", "cli_version": "test_version", "source": "vscode", + "model_provider": "test-provider", })]; let expected_cursor1: Cursor = serde_json::from_str(&format!("\"2025-03-04T09-00-00|{u4}\"")).unwrap(); @@ -338,6 +385,8 @@ async fn test_pagination_cursor() { 2, page1.next_cursor.as_ref(), INTERACTIVE_SESSION_SOURCES, + Some(provider_filter.as_slice()), + TEST_PROVIDER, ) .await .unwrap(); @@ -361,6 +410,7 @@ async fn test_pagination_cursor() { "originator": "test_originator", "cli_version": "test_version", "source": "vscode", + "model_provider": "test-provider", })]; let head_2 = vec![serde_json::json!({ "id": u2, @@ -370,6 +420,7 @@ async fn test_pagination_cursor() { "originator": "test_originator", "cli_version": "test_version", "source": "vscode", + "model_provider": "test-provider", })]; let expected_cursor2: Cursor = serde_json::from_str(&format!("\"2025-03-02T09-00-00|{u2}\"")).unwrap(); @@ -401,6 +452,8 @@ async fn test_pagination_cursor() { 2, page2.next_cursor.as_ref(), INTERACTIVE_SESSION_SOURCES, + Some(provider_filter.as_slice()), + TEST_PROVIDER, ) .await .unwrap(); @@ -418,9 +471,8 @@ async fn test_pagination_cursor() { "originator": "test_originator", "cli_version": "test_version", "source": "vscode", + "model_provider": "test-provider", })]; - let expected_cursor3: Cursor = - serde_json::from_str(&format!("\"2025-03-01T09-00-00|{u1}\"")).unwrap(); let expected_page3 = ConversationsPage { items: vec![ConversationItem { path: p1, @@ -429,7 +481,7 @@ async fn test_pagination_cursor() { created_at: Some("2025-03-01T09-00-00".into()), updated_at: Some("2025-03-01T09-00-00".into()), }], - next_cursor: Some(expected_cursor3), + next_cursor: None, num_scanned_files: 5, // scanned 05, 04 (anchor), 03, 02 (anchor), 01 reached_scan_cap: false, }; @@ -445,9 +497,17 @@ async fn test_get_conversation_contents() { let ts = "2025-04-01T10-30-00"; write_session_file(home, ts, uuid, 2, Some(SessionSource::VSCode)).unwrap(); - let page = get_conversations(home, 1, None, INTERACTIVE_SESSION_SOURCES) - .await - .unwrap(); + let provider_filter = provider_vec(&[TEST_PROVIDER]); + let page = get_conversations( + home, + 1, + None, + INTERACTIVE_SESSION_SOURCES, + Some(provider_filter.as_slice()), + TEST_PROVIDER, + ) + .await + .unwrap(); let path = &page.items[0].path; let content = get_conversation(path).await.unwrap(); @@ -467,8 +527,8 @@ async fn test_get_conversation_contents() { "originator": "test_originator", "cli_version": "test_version", "source": "vscode", + "model_provider": "test-provider", })]; - let expected_cursor: Cursor = serde_json::from_str(&format!("\"{ts}|{uuid}\"")).unwrap(); let expected_page = ConversationsPage { items: vec![ConversationItem { path: expected_path, @@ -477,7 +537,7 @@ async fn test_get_conversation_contents() { created_at: Some(ts.into()), updated_at: Some(ts.into()), }], - next_cursor: Some(expected_cursor), + next_cursor: None, num_scanned_files: 1, reached_scan_cap: false, }; @@ -495,6 +555,7 @@ async fn test_get_conversation_contents() { "originator": "test_originator", "cli_version": "test_version", "source": "vscode", + "model_provider": "test-provider", } }); let user_event = serde_json::json!({ @@ -532,6 +593,7 @@ async fn test_tail_includes_last_response_items() -> Result<()> { originator: "test_originator".into(), cli_version: "test_version".into(), source: SessionSource::VSCode, + model_provider: Some("test-provider".into()), }, git: None, }), @@ -563,7 +625,16 @@ async fn test_tail_includes_last_response_items() -> Result<()> { } drop(file); - let page = get_conversations(home, 1, None, INTERACTIVE_SESSION_SOURCES).await?; + let provider_filter = provider_vec(&[TEST_PROVIDER]); + let page = get_conversations( + home, + 1, + None, + INTERACTIVE_SESSION_SOURCES, + Some(provider_filter.as_slice()), + TEST_PROVIDER, + ) + .await?; let item = page.items.first().expect("conversation item"); let tail_len = item.tail.len(); assert_eq!(tail_len, 10usize.min(total_messages)); @@ -615,6 +686,7 @@ async fn test_tail_handles_short_sessions() -> Result<()> { originator: "test_originator".into(), cli_version: "test_version".into(), source: SessionSource::VSCode, + model_provider: Some("test-provider".into()), }, git: None, }), @@ -645,7 +717,16 @@ async fn test_tail_handles_short_sessions() -> Result<()> { } drop(file); - let page = get_conversations(home, 1, None, INTERACTIVE_SESSION_SOURCES).await?; + let provider_filter = provider_vec(&[TEST_PROVIDER]); + let page = get_conversations( + home, + 1, + None, + INTERACTIVE_SESSION_SOURCES, + Some(provider_filter.as_slice()), + TEST_PROVIDER, + ) + .await?; let tail = &page.items.first().expect("conversation item").tail; assert_eq!(tail.len(), 3); @@ -699,6 +780,7 @@ async fn test_tail_skips_trailing_non_responses() -> Result<()> { originator: "test_originator".into(), cli_version: "test_version".into(), source: SessionSource::VSCode, + model_provider: Some("test-provider".into()), }, git: None, }), @@ -743,7 +825,16 @@ async fn test_tail_skips_trailing_non_responses() -> Result<()> { writeln!(file, "{}", serde_json::to_string(&shutdown_event)?)?; drop(file); - let page = get_conversations(home, 1, None, INTERACTIVE_SESSION_SOURCES).await?; + let provider_filter = provider_vec(&[TEST_PROVIDER]); + let page = get_conversations( + home, + 1, + None, + INTERACTIVE_SESSION_SOURCES, + Some(provider_filter.as_slice()), + TEST_PROVIDER, + ) + .await?; let tail = &page.items.first().expect("conversation item").tail; let expected: Vec = (0..4) @@ -785,9 +876,17 @@ async fn test_stable_ordering_same_second_pagination() { write_session_file(home, ts, u2, 0, Some(SessionSource::VSCode)).unwrap(); write_session_file(home, ts, u3, 0, Some(SessionSource::VSCode)).unwrap(); - let page1 = get_conversations(home, 2, None, INTERACTIVE_SESSION_SOURCES) - .await - .unwrap(); + let provider_filter = provider_vec(&[TEST_PROVIDER]); + let page1 = get_conversations( + home, + 2, + None, + INTERACTIVE_SESSION_SOURCES, + Some(provider_filter.as_slice()), + TEST_PROVIDER, + ) + .await + .unwrap(); let p3 = home .join("sessions") @@ -810,6 +909,7 @@ async fn test_stable_ordering_same_second_pagination() { "originator": "test_originator", "cli_version": "test_version", "source": "vscode", + "model_provider": "test-provider", })] }; let expected_cursor1: Cursor = serde_json::from_str(&format!("\"{ts}|{u2}\"")).unwrap(); @@ -841,6 +941,8 @@ async fn test_stable_ordering_same_second_pagination() { 2, page1.next_cursor.as_ref(), INTERACTIVE_SESSION_SOURCES, + Some(provider_filter.as_slice()), + TEST_PROVIDER, ) .await .unwrap(); @@ -850,7 +952,6 @@ async fn test_stable_ordering_same_second_pagination() { .join("07") .join("01") .join(format!("rollout-2025-07-01T00-00-00-{u1}.jsonl")); - let expected_cursor2: Cursor = serde_json::from_str(&format!("\"{ts}|{u1}\"")).unwrap(); let expected_page2 = ConversationsPage { items: vec![ConversationItem { path: p1, @@ -859,7 +960,7 @@ async fn test_stable_ordering_same_second_pagination() { created_at: Some(ts.to_string()), updated_at: Some(ts.to_string()), }], - next_cursor: Some(expected_cursor2), + next_cursor: None, num_scanned_files: 3, // scanned u3, u2 (anchor), u1 reached_scan_cap: false, }; @@ -891,9 +992,17 @@ async fn test_source_filter_excludes_non_matching_sessions() { ) .unwrap(); - let interactive_only = get_conversations(home, 10, None, INTERACTIVE_SESSION_SOURCES) - .await - .unwrap(); + let provider_filter = provider_vec(&[TEST_PROVIDER]); + let interactive_only = get_conversations( + home, + 10, + None, + INTERACTIVE_SESSION_SOURCES, + Some(provider_filter.as_slice()), + TEST_PROVIDER, + ) + .await + .unwrap(); let paths: Vec<_> = interactive_only .items .iter() @@ -905,7 +1014,7 @@ async fn test_source_filter_excludes_non_matching_sessions() { path.ends_with("rollout-2025-08-02T10-00-00-00000000-0000-0000-0000-00000000002a.jsonl") })); - let all_sessions = get_conversations(home, 10, None, NO_SOURCE_FILTER) + let all_sessions = get_conversations(home, 10, None, NO_SOURCE_FILTER, None, TEST_PROVIDER) .await .unwrap(); let all_paths: Vec<_> = all_sessions @@ -921,3 +1030,102 @@ async fn test_source_filter_excludes_non_matching_sessions() { path.ends_with("rollout-2025-08-01T10-00-00-00000000-0000-0000-0000-00000000004d.jsonl") })); } + +#[tokio::test] +async fn test_model_provider_filter_selects_only_matching_sessions() -> Result<()> { + let temp = TempDir::new().unwrap(); + let home = temp.path(); + + let openai_id = Uuid::from_u128(1); + let beta_id = Uuid::from_u128(2); + let none_id = Uuid::from_u128(3); + + write_session_file_with_provider( + home, + "2025-09-01T12-00-00", + openai_id, + 1, + Some(SessionSource::VSCode), + Some("openai"), + )?; + write_session_file_with_provider( + home, + "2025-09-01T11-00-00", + beta_id, + 1, + Some(SessionSource::VSCode), + Some("beta"), + )?; + write_session_file_with_provider( + home, + "2025-09-01T10-00-00", + none_id, + 1, + Some(SessionSource::VSCode), + None, + )?; + + let openai_id_str = openai_id.to_string(); + let none_id_str = none_id.to_string(); + let openai_filter = provider_vec(&["openai"]); + let openai_sessions = get_conversations( + home, + 10, + None, + NO_SOURCE_FILTER, + Some(openai_filter.as_slice()), + "openai", + ) + .await?; + assert_eq!(openai_sessions.items.len(), 2); + let openai_ids: Vec<_> = openai_sessions + .items + .iter() + .filter_map(|item| { + item.head + .first() + .and_then(|value| value.get("id")) + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }) + .collect(); + assert!(openai_ids.contains(&openai_id_str)); + assert!(openai_ids.contains(&none_id_str)); + + let beta_filter = provider_vec(&["beta"]); + let beta_sessions = get_conversations( + home, + 10, + None, + NO_SOURCE_FILTER, + Some(beta_filter.as_slice()), + "openai", + ) + .await?; + assert_eq!(beta_sessions.items.len(), 1); + let beta_id_str = beta_id.to_string(); + let beta_head = beta_sessions + .items + .first() + .and_then(|item| item.head.first()) + .and_then(|value| value.get("id")) + .and_then(serde_json::Value::as_str); + assert_eq!(beta_head, Some(beta_id_str.as_str())); + + let unknown_filter = provider_vec(&["unknown"]); + let unknown_sessions = get_conversations( + home, + 10, + None, + NO_SOURCE_FILTER, + Some(unknown_filter.as_slice()), + "openai", + ) + .await?; + assert!(unknown_sessions.items.is_empty()); + + let all_sessions = get_conversations(home, 10, None, NO_SOURCE_FILTER, None, "openai").await?; + assert_eq!(all_sessions.items.len(), 3); + + Ok(()) +} diff --git a/codex-rs/core/src/sandboxing/assessment.rs b/codex-rs/core/src/sandboxing/assessment.rs new file mode 100644 index 0000000000..f02a90b46e --- /dev/null +++ b/codex-rs/core/src/sandboxing/assessment.rs @@ -0,0 +1,275 @@ +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; + +use crate::AuthManager; +use crate::ModelProviderInfo; +use crate::client::ModelClient; +use crate::client_common::Prompt; +use crate::client_common::ResponseEvent; +use crate::config::Config; +use crate::protocol::SandboxPolicy; +use askama::Template; +use codex_otel::otel_event_manager::OtelEventManager; +use codex_protocol::ConversationId; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::SandboxCommandAssessment; +use futures::StreamExt; +use serde_json::json; +use tokio::time::timeout; +use tracing::warn; + +const SANDBOX_ASSESSMENT_TIMEOUT: Duration = Duration::from_secs(5); + +const SANDBOX_RISK_CATEGORY_VALUES: &[&str] = &[ + "data_deletion", + "data_exfiltration", + "privilege_escalation", + "system_modification", + "network_access", + "resource_exhaustion", + "compliance", +]; + +#[derive(Template)] +#[template(path = "sandboxing/assessment_prompt.md", escape = "none")] +struct SandboxAssessmentPromptTemplate<'a> { + platform: &'a str, + sandbox_policy: &'a str, + filesystem_roots: Option<&'a str>, + working_directory: &'a str, + command_argv: &'a str, + command_joined: &'a str, + sandbox_failure_message: Option<&'a str>, +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn assess_command( + config: Arc, + provider: ModelProviderInfo, + auth_manager: Arc, + parent_otel: &OtelEventManager, + conversation_id: ConversationId, + call_id: &str, + command: &[String], + sandbox_policy: &SandboxPolicy, + cwd: &Path, + failure_message: Option<&str>, +) -> Option { + if !config.experimental_sandbox_command_assessment || command.is_empty() { + return None; + } + + let command_json = serde_json::to_string(command).unwrap_or_else(|_| "[]".to_string()); + let command_joined = + shlex::try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" ")); + let failure = failure_message + .map(str::trim) + .filter(|msg| !msg.is_empty()) + .map(str::to_string); + + let cwd_str = cwd.to_string_lossy().to_string(); + let sandbox_summary = summarize_sandbox_policy(sandbox_policy); + let mut roots = sandbox_roots_for_prompt(sandbox_policy, cwd); + roots.sort(); + roots.dedup(); + + let platform = std::env::consts::OS; + let roots_formatted = roots.iter().map(|root| root.to_string_lossy().to_string()); + let filesystem_roots = match roots_formatted.collect::>() { + collected if collected.is_empty() => None, + collected => Some(collected.join(", ")), + }; + + let prompt_template = SandboxAssessmentPromptTemplate { + platform, + sandbox_policy: sandbox_summary.as_str(), + filesystem_roots: filesystem_roots.as_deref(), + working_directory: cwd_str.as_str(), + command_argv: command_json.as_str(), + command_joined: command_joined.as_str(), + sandbox_failure_message: failure.as_deref(), + }; + let rendered_prompt = match prompt_template.render() { + Ok(rendered) => rendered, + Err(err) => { + warn!("failed to render sandbox assessment prompt: {err}"); + return None; + } + }; + let (system_prompt_section, user_prompt_section) = match rendered_prompt.split_once("\n---\n") { + Some(split) => split, + None => { + warn!("rendered sandbox assessment prompt missing separator"); + return None; + } + }; + let system_prompt = system_prompt_section + .strip_prefix("System Prompt:\n") + .unwrap_or(system_prompt_section) + .trim() + .to_string(); + let user_prompt = user_prompt_section + .strip_prefix("User Prompt:\n") + .unwrap_or(user_prompt_section) + .trim() + .to_string(); + + let prompt = Prompt { + input: vec![ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { text: user_prompt }], + }], + tools: Vec::new(), + parallel_tool_calls: false, + base_instructions_override: Some(system_prompt), + output_schema: Some(sandbox_assessment_schema()), + }; + + let child_otel = + parent_otel.with_model(config.model.as_str(), config.model_family.slug.as_str()); + + let client = ModelClient::new( + Arc::clone(&config), + Some(auth_manager), + child_otel, + provider, + config.model_reasoning_effort, + config.model_reasoning_summary, + conversation_id, + ); + + let start = Instant::now(); + let assessment_result = timeout(SANDBOX_ASSESSMENT_TIMEOUT, async move { + let mut stream = client.stream(&prompt).await?; + let mut last_json: Option = None; + while let Some(event) = stream.next().await { + match event { + Ok(ResponseEvent::OutputItemDone(item)) => { + if let Some(text) = response_item_text(&item) { + last_json = Some(text); + } + } + Ok(ResponseEvent::RateLimits(_)) => {} + Ok(ResponseEvent::Completed { .. }) => break, + Ok(_) => continue, + Err(err) => return Err(err), + } + } + Ok(last_json) + }) + .await; + let duration = start.elapsed(); + parent_otel.sandbox_assessment_latency(call_id, duration); + + match assessment_result { + Ok(Ok(Some(raw))) => match serde_json::from_str::(raw.trim()) { + Ok(assessment) => { + parent_otel.sandbox_assessment( + call_id, + "success", + Some(assessment.risk_level), + &assessment.risk_categories, + duration, + ); + return Some(assessment); + } + Err(err) => { + warn!("failed to parse sandbox assessment JSON: {err}"); + parent_otel.sandbox_assessment(call_id, "parse_error", None, &[], duration); + } + }, + Ok(Ok(None)) => { + warn!("sandbox assessment response did not include any message"); + parent_otel.sandbox_assessment(call_id, "no_output", None, &[], duration); + } + Ok(Err(err)) => { + warn!("sandbox assessment failed: {err}"); + parent_otel.sandbox_assessment(call_id, "model_error", None, &[], duration); + } + Err(_) => { + warn!("sandbox assessment timed out"); + parent_otel.sandbox_assessment(call_id, "timeout", None, &[], duration); + } + } + + None +} + +fn summarize_sandbox_policy(policy: &SandboxPolicy) -> String { + match policy { + SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(), + SandboxPolicy::ReadOnly => "read-only".to_string(), + SandboxPolicy::WorkspaceWrite { network_access, .. } => { + let network = if *network_access { + "network" + } else { + "no-network" + }; + format!("workspace-write (network_access={network})") + } + } +} + +fn sandbox_roots_for_prompt(policy: &SandboxPolicy, cwd: &Path) -> Vec { + let mut roots = vec![cwd.to_path_buf()]; + if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = policy { + roots.extend(writable_roots.iter().cloned()); + } + roots +} + +fn sandbox_assessment_schema() -> serde_json::Value { + json!({ + "type": "object", + "required": ["description", "risk_level", "risk_categories"], + "properties": { + "description": { + "type": "string", + "minLength": 1, + "maxLength": 500 + }, + "risk_level": { + "type": "string", + "enum": ["low", "medium", "high"] + }, + "risk_categories": { + "type": "array", + "items": { + "type": "string", + "enum": SANDBOX_RISK_CATEGORY_VALUES + } + } + }, + "additionalProperties": false + }) +} + +fn response_item_text(item: &ResponseItem) -> Option { + match item { + ResponseItem::Message { content, .. } => { + let mut buffers: Vec<&str> = Vec::new(); + for segment in content { + match segment { + ContentItem::InputText { text } | ContentItem::OutputText { text } => { + if !text.is_empty() { + buffers.push(text); + } + } + ContentItem::InputImage { .. } => {} + } + } + if buffers.is_empty() { + None + } else { + Some(buffers.join("\n")) + } + } + ResponseItem::FunctionCallOutput { output, .. } => Some(output.content.clone()), + _ => None, + } +} diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index d632b5da19..c11f3588fe 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -5,6 +5,9 @@ Build platform wrappers and produce ExecEnv for execution. Owns low‑level sandbox placement and transformation of portable CommandSpec into a ready‑to‑spawn environment. */ + +pub mod assessment; + use crate::exec::ExecToolCallOutput; use crate::exec::SandboxType; use crate::exec::StdoutStream; diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index f8a58c3b2a..7c4603d9ec 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -12,7 +12,6 @@ use crate::protocol::TokenUsageInfo; pub(crate) struct SessionState { pub(crate) session_configuration: SessionConfiguration, pub(crate) history: ConversationHistory, - pub(crate) token_info: Option, pub(crate) latest_rate_limits: Option, } @@ -22,7 +21,6 @@ impl SessionState { Self { session_configuration, history: ConversationHistory::new(), - token_info: None, latest_rate_limits: None, } } @@ -54,11 +52,11 @@ impl SessionState { usage: &TokenUsage, model_context_window: Option, ) { - self.token_info = TokenUsageInfo::new_or_append( - &self.token_info, - &Some(usage.clone()), - model_context_window, - ); + self.history.update_token_info(usage, model_context_window); + } + + pub(crate) fn token_info(&self) -> Option { + self.history.token_info() } pub(crate) fn set_rate_limits(&mut self, snapshot: RateLimitSnapshot) { @@ -68,17 +66,10 @@ impl SessionState { pub(crate) fn token_info_and_rate_limits( &self, ) -> (Option, Option) { - (self.token_info.clone(), self.latest_rate_limits.clone()) + (self.token_info(), self.latest_rate_limits.clone()) } pub(crate) fn set_token_usage_full(&mut self, context_window: i64) { - match &mut self.token_info { - Some(info) => info.fill_to_context_window(context_window), - None => { - self.token_info = Some(TokenUsageInfo::full_context_window(context_window)); - } - } + self.history.set_token_usage_full(context_window); } - - // Pending input/approval moved to TurnState. } diff --git a/codex-rs/core/src/tasks/ghost_snapshot.rs b/codex-rs/core/src/tasks/ghost_snapshot.rs new file mode 100644 index 0000000000..f421a91e4e --- /dev/null +++ b/codex-rs/core/src/tasks/ghost_snapshot.rs @@ -0,0 +1,110 @@ +use crate::codex::TurnContext; +use crate::state::TaskKind; +use crate::tasks::SessionTask; +use crate::tasks::SessionTaskContext; +use async_trait::async_trait; +use codex_git_tooling::CreateGhostCommitOptions; +use codex_git_tooling::GitToolingError; +use codex_git_tooling::create_ghost_commit; +use codex_protocol::models::ResponseItem; +use codex_protocol::user_input::UserInput; +use codex_utils_readiness::Readiness; +use codex_utils_readiness::Token; +use std::sync::Arc; +use tokio_util::sync::CancellationToken; +use tracing::info; +use tracing::warn; + +pub(crate) struct GhostSnapshotTask { + token: Token, +} + +#[async_trait] +impl SessionTask for GhostSnapshotTask { + fn kind(&self) -> TaskKind { + TaskKind::Regular + } + + async fn run( + self: Arc, + session: Arc, + ctx: Arc, + _input: Vec, + cancellation_token: CancellationToken, + ) -> Option { + tokio::task::spawn(async move { + let token = self.token; + let ctx_for_task = Arc::clone(&ctx); + let cancelled = tokio::select! { + _ = cancellation_token.cancelled() => true, + _ = async { + let repo_path = ctx_for_task.cwd.clone(); + // Required to run in a dedicated blocking pool. + match tokio::task::spawn_blocking(move || { + let options = CreateGhostCommitOptions::new(&repo_path); + create_ghost_commit(&options) + }) + .await + { + Ok(Ok(ghost_commit)) => { + info!("ghost snapshot blocking task finished"); + session + .session + .record_conversation_items(&ctx, &[ResponseItem::GhostSnapshot { + ghost_commit: ghost_commit.clone(), + }]) + .await; + info!("ghost commit captured: {}", ghost_commit.id()); + } + Ok(Err(err)) => { + warn!( + sub_id = ctx_for_task.sub_id.as_str(), + "failed to capture ghost snapshot: {err}" + ); + let message = match err { + GitToolingError::NotAGitRepository { .. } => { + "Snapshots disabled: current directory is not a Git repository." + .to_string() + } + _ => format!("Snapshots disabled after ghost snapshot error: {err}."), + }; + session + .session + .notify_background_event(&ctx_for_task, message) + .await; + } + Err(err) => { + warn!( + sub_id = ctx_for_task.sub_id.as_str(), + "ghost snapshot task panicked: {err}" + ); + let message = + format!("Snapshots disabled after ghost snapshot panic: {err}."); + session + .session + .notify_background_event(&ctx_for_task, message) + .await; + } + } + } => false, + }; + + if cancelled { + info!("ghost snapshot task cancelled"); + } + + match ctx.tool_call_gate.mark_ready(token).await { + Ok(true) => info!("ghost snapshot gate marked ready"), + Ok(false) => warn!("ghost snapshot gate already ready"), + Err(err) => warn!("failed to mark ghost snapshot ready: {err}"), + } + }); + None + } +} + +impl GhostSnapshotTask { + pub(crate) fn new(token: Token) -> Self { + Self { token } + } +} diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 79527814e3..ae22abe8cc 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -1,6 +1,8 @@ mod compact; +mod ghost_snapshot; mod regular; mod review; +mod undo; use std::sync::Arc; use std::time::Duration; @@ -25,8 +27,10 @@ use crate::state::TaskKind; use codex_protocol::user_input::UserInput; pub(crate) use compact::CompactTask; +pub(crate) use ghost_snapshot::GhostSnapshotTask; pub(crate) use regular::RegularTask; pub(crate) use review::ReviewTask; +pub(crate) use undo::UndoTask; const GRACEFULL_INTERRUPTION_TIMEOUT_MS: u64 = 100; @@ -46,10 +50,28 @@ impl SessionTaskContext { } } +/// Async task that drives a [`Session`] turn. +/// +/// Implementations encapsulate a specific Codex workflow (regular chat, +/// reviews, ghost snapshots, etc.). Each task instance is owned by a +/// [`Session`] and executed on a background Tokio task. The trait is +/// intentionally small: implementers identify themselves via +/// [`SessionTask::kind`], perform their work in [`SessionTask::run`], and may +/// release resources in [`SessionTask::abort`]. #[async_trait] pub(crate) trait SessionTask: Send + Sync + 'static { + /// Describes the type of work the task performs so the session can + /// surface it in telemetry and UI. fn kind(&self) -> TaskKind; + /// Executes the task until completion or cancellation. + /// + /// Implementations typically stream protocol events using `session` and + /// `ctx`, returning an optional final agent message when finished. The + /// provided `cancellation_token` is cancelled when the session requests an + /// abort; implementers should watch for it and terminate quickly once it + /// fires. Returning [`Some`] yields a final message that + /// [`Session::on_task_finished`] will emit to the client. async fn run( self: Arc, session: Arc, @@ -58,6 +80,11 @@ pub(crate) trait SessionTask: Send + Sync + 'static { cancellation_token: CancellationToken, ) -> Option; + /// Gives the task a chance to perform cleanup after an abort. + /// + /// The default implementation is a no-op; override this if additional + /// teardown or notifications are required once + /// [`Session::abort_all_tasks`] cancels the task. async fn abort(&self, session: Arc, ctx: Arc) { let _ = (session, ctx); } diff --git a/codex-rs/core/src/tasks/undo.rs b/codex-rs/core/src/tasks/undo.rs new file mode 100644 index 0000000000..e834eea02d --- /dev/null +++ b/codex-rs/core/src/tasks/undo.rs @@ -0,0 +1,117 @@ +use std::sync::Arc; + +use crate::codex::TurnContext; +use crate::protocol::EventMsg; +use crate::protocol::UndoCompletedEvent; +use crate::protocol::UndoStartedEvent; +use crate::state::TaskKind; +use crate::tasks::SessionTask; +use crate::tasks::SessionTaskContext; +use async_trait::async_trait; +use codex_git_tooling::restore_ghost_commit; +use codex_protocol::models::ResponseItem; +use codex_protocol::user_input::UserInput; +use tokio_util::sync::CancellationToken; +use tracing::error; +use tracing::info; +use tracing::warn; + +pub(crate) struct UndoTask; + +impl UndoTask { + pub(crate) fn new() -> Self { + Self + } +} + +#[async_trait] +impl SessionTask for UndoTask { + fn kind(&self) -> TaskKind { + TaskKind::Regular + } + + async fn run( + self: Arc, + session: Arc, + ctx: Arc, + _input: Vec, + cancellation_token: CancellationToken, + ) -> Option { + let sess = session.clone_session(); + sess.send_event( + ctx.as_ref(), + EventMsg::UndoStarted(UndoStartedEvent { + message: Some("Undo in progress...".to_string()), + }), + ) + .await; + + if cancellation_token.is_cancelled() { + sess.send_event( + ctx.as_ref(), + EventMsg::UndoCompleted(UndoCompletedEvent { + success: false, + message: Some("Undo cancelled.".to_string()), + }), + ) + .await; + return None; + } + + let mut history = sess.clone_history().await; + let mut items = history.get_history(); + let mut completed = UndoCompletedEvent { + success: false, + message: None, + }; + + let Some((idx, ghost_commit)) = + items + .iter() + .enumerate() + .rev() + .find_map(|(idx, item)| match item { + ResponseItem::GhostSnapshot { ghost_commit } => { + Some((idx, ghost_commit.clone())) + } + _ => None, + }) + else { + completed.message = Some("No ghost snapshot available to undo.".to_string()); + sess.send_event(ctx.as_ref(), EventMsg::UndoCompleted(completed)) + .await; + return None; + }; + + let commit_id = ghost_commit.id().to_string(); + let repo_path = ctx.cwd.clone(); + let restore_result = + tokio::task::spawn_blocking(move || restore_ghost_commit(&repo_path, &ghost_commit)) + .await; + + match restore_result { + Ok(Ok(())) => { + items.remove(idx); + sess.replace_history(items).await; + let short_id: String = commit_id.chars().take(7).collect(); + info!(commit_id = commit_id, "Undo restored ghost snapshot"); + completed.success = true; + completed.message = Some(format!("Undo restored snapshot {short_id}.")); + } + Ok(Err(err)) => { + let message = format!("Failed to restore snapshot {commit_id}: {err}"); + warn!("{message}"); + completed.message = Some(message); + } + Err(err) => { + let message = format!("Failed to restore snapshot {commit_id}: {err}"); + error!("{message}"); + completed.message = Some(message); + } + } + + sess.send_event(ctx.as_ref(), EventMsg::UndoCompleted(completed)) + .await; + None + } +} diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs index d45ac7cd5c..5578295547 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -200,10 +200,51 @@ impl ToolEmitter { ) => { emit_patch_end(ctx, String::new(), (*message).to_string(), false).await; } - (Self::UnifiedExec { command, cwd, .. }, _) => { - // TODO(jif) add end and failures. + (Self::UnifiedExec { command, cwd, .. }, ToolEventStage::Begin) => { emit_exec_command_begin(ctx, &[command.to_string()], cwd.as_path()).await; } + (Self::UnifiedExec { .. }, ToolEventStage::Success(output)) => { + emit_exec_end( + ctx, + output.stdout.text.clone(), + output.stderr.text.clone(), + output.aggregated_output.text.clone(), + output.exit_code, + output.duration, + format_exec_output_str(&output), + ) + .await; + } + ( + Self::UnifiedExec { .. }, + ToolEventStage::Failure(ToolEventFailure::Output(output)), + ) => { + emit_exec_end( + ctx, + output.stdout.text.clone(), + output.stderr.text.clone(), + output.aggregated_output.text.clone(), + output.exit_code, + output.duration, + format_exec_output_str(&output), + ) + .await; + } + ( + Self::UnifiedExec { .. }, + ToolEventStage::Failure(ToolEventFailure::Message(message)), + ) => { + emit_exec_end( + ctx, + String::new(), + (*message).to_string(), + (*message).to_string(), + -1, + Duration::ZERO, + format_exec_output(&message), + ) + .await; + } } } diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index a6e6f195e1..126b734242 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -100,7 +100,7 @@ impl ToolHandler for ApplyPatchHandler { let req = ApplyPatchRequest { patch: apply.action.patch.clone(), - cwd, + cwd: apply.action.cwd.clone(), timeout_ms: None, user_explicitly_approved: apply.user_explicitly_approved_this_action, codex_exe: turn.codex_linux_sandbox_exe.clone(), diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 12dd746fc7..cab313077f 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -154,7 +154,7 @@ impl ShellHandler { let req = ApplyPatchRequest { patch: apply.action.patch.clone(), - cwd: exec_params.cwd.clone(), + cwd: apply.action.cwd.clone(), timeout_ms: exec_params.timeout_ms, user_explicitly_approved: apply.user_explicitly_approved_this_action, codex_exe: turn.codex_linux_sandbox_exe.clone(), diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 0ad3739c57..7d1102121e 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -5,6 +5,9 @@ use serde::Deserialize; use serde::Serialize; use crate::function_tool::FunctionCallError; +use crate::protocol::EventMsg; +use crate::protocol::ExecCommandOutputDeltaEvent; +use crate::protocol::ExecOutputStream; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; @@ -87,11 +90,7 @@ impl ToolHandler for UnifiedExecHandler { }; let manager: &UnifiedExecSessionManager = &session.services.unified_exec_manager; - let context = UnifiedExecContext { - session: &session, - turn: turn.as_ref(), - call_id: &call_id, - }; + let context = UnifiedExecContext::new(session.clone(), turn.clone(), call_id.clone()); let response = match tool_name.as_str() { "exec_command" => { @@ -101,8 +100,12 @@ impl ToolHandler for UnifiedExecHandler { )) })?; - let event_ctx = - ToolEventCtx::new(context.session, context.turn, context.call_id, None); + let event_ctx = ToolEventCtx::new( + context.session.as_ref(), + context.turn.as_ref(), + &context.call_id, + None, + ); let emitter = ToolEmitter::unified_exec(args.cmd.clone(), context.turn.cwd.clone(), true); emitter.emit(event_ctx, ToolEventStage::Begin).await; @@ -148,6 +151,18 @@ impl ToolHandler for UnifiedExecHandler { } }; + // Emit a delta event with the chunk of output we just produced, if any. + if !response.output.is_empty() { + let delta = ExecCommandOutputDeltaEvent { + call_id: response.event_call_id.clone(), + stream: ExecOutputStream::Stdout, + chunk: response.output.as_bytes().to_vec(), + }; + session + .send_event(turn.as_ref(), EventMsg::ExecCommandOutputDelta(delta)) + .await; + } + let content = serialize_response(&response).map_err(|err| { FunctionCallError::RespondToModel(format!( "failed to serialize unified exec output: {err:?}" diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 38aac5c1ed..e39c0703f8 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -7,9 +7,11 @@ retry without sandbox on denial (no re‑approval thanks to caching). */ use crate::error::CodexErr; use crate::error::SandboxErr; +use crate::error::get_error_message_ui; use crate::exec::ExecToolCallOutput; use crate::sandboxing::SandboxManager; use crate::tools::sandboxing::ApprovalCtx; +use crate::tools::sandboxing::ProvidesSandboxRetryData; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::ToolCtx; use crate::tools::sandboxing::ToolError; @@ -38,6 +40,7 @@ impl ToolOrchestrator { ) -> Result where T: ToolRuntime, + Rq: ProvidesSandboxRetryData, { let otel = turn_ctx.client.get_otel_event_manager(); let otel_tn = &tool_ctx.tool_name; @@ -56,6 +59,7 @@ impl ToolOrchestrator { turn: turn_ctx, call_id: &tool_ctx.call_id, retry_reason: None, + risk: None, }; let decision = tool.start_approval_async(req, approval_ctx).await; @@ -107,12 +111,33 @@ impl ToolOrchestrator { // Ask for approval before retrying without sandbox. if !tool.should_bypass_approval(approval_policy, already_approved) { + let mut risk = None; + + if let Some(metadata) = req.sandbox_retry_data() { + let err = SandboxErr::Denied { + output: output.clone(), + }; + let friendly = get_error_message_ui(&CodexErr::Sandbox(err)); + let failure_summary = format!("failed in sandbox: {friendly}"); + + risk = tool_ctx + .session + .assess_sandbox_command( + turn_ctx, + &tool_ctx.call_id, + &metadata.command, + Some(failure_summary.as_str()), + ) + .await; + } + let reason_msg = build_denial_reason_from_output(output.as_ref()); let approval_ctx = ApprovalCtx { session: tool_ctx.session, turn: turn_ctx, call_id: &tool_ctx.call_id, retry_reason: Some(reason_msg), + risk, }; let decision = tool.start_approval_async(req, approval_ctx).await; diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index eae181c1c5..449b8e6553 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use tokio::sync::RwLock; use tokio_util::either::Either; +use tokio_util::sync::CancellationToken; use tokio_util::task::AbortOnDropHandle; use crate::codex::Session; @@ -9,9 +10,12 @@ use crate::codex::TurnContext; use crate::error::CodexErr; use crate::function_tool::FunctionCallError; use crate::tools::context::SharedTurnDiffTracker; +use crate::tools::context::ToolPayload; use crate::tools::router::ToolCall; use crate::tools::router::ToolRouter; +use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseInputItem; +use codex_utils_readiness::Readiness; pub(crate) struct ToolCallRuntime { router: Arc, @@ -40,6 +44,7 @@ impl ToolCallRuntime { pub(crate) fn handle_tool_call( &self, call: ToolCall, + cancellation_token: CancellationToken, ) -> impl std::future::Future> { let supports_parallel = self.router.tool_supports_parallel(&call.tool_name); @@ -48,18 +53,28 @@ impl ToolCallRuntime { let turn = Arc::clone(&self.turn_context); let tracker = Arc::clone(&self.tracker); let lock = Arc::clone(&self.parallel_execution); + let aborted_response = Self::aborted_response(&call); + let readiness = self.turn_context.tool_call_gate.clone(); let handle: AbortOnDropHandle> = AbortOnDropHandle::new(tokio::spawn(async move { - let _guard = if supports_parallel { - Either::Left(lock.read().await) - } else { - Either::Right(lock.write().await) - }; + tokio::select! { + _ = cancellation_token.cancelled() => Ok(aborted_response), + res = async { + tracing::info!("waiting for tool gate"); + readiness.wait_ready().await; + tracing::info!("tool gate released"); + let _guard = if supports_parallel { + Either::Left(lock.read().await) + } else { + Either::Right(lock.write().await) + }; - router - .dispatch_tool_call(session, turn, tracker, call) - .await + router + .dispatch_tool_call(session, turn, tracker, call) + .await + } => res, + } })); async move { @@ -74,3 +89,25 @@ impl ToolCallRuntime { } } } + +impl ToolCallRuntime { + fn aborted_response(call: &ToolCall) -> ResponseInputItem { + match &call.payload { + ToolPayload::Custom { .. } => ResponseInputItem::CustomToolCallOutput { + call_id: call.call_id.clone(), + output: "aborted".to_string(), + }, + ToolPayload::Mcp { .. } => ResponseInputItem::McpToolCallOutput { + call_id: call.call_id.clone(), + result: Err("aborted".to_string()), + }, + _ => ResponseInputItem::FunctionCallOutput { + call_id: call.call_id.clone(), + output: FunctionCallOutputPayload { + content: "aborted".to_string(), + success: None, + }, + }, + } + } +} diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index 18abbfee4a..0cdddd5087 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -10,7 +10,9 @@ use crate::sandboxing::CommandSpec; use crate::sandboxing::execute_env; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; +use crate::tools::sandboxing::ProvidesSandboxRetryData; use crate::tools::sandboxing::SandboxAttempt; +use crate::tools::sandboxing::SandboxRetryData; use crate::tools::sandboxing::Sandboxable; use crate::tools::sandboxing::SandboxablePreference; use crate::tools::sandboxing::ToolCtx; @@ -32,6 +34,12 @@ pub struct ApplyPatchRequest { pub codex_exe: Option, } +impl ProvidesSandboxRetryData for ApplyPatchRequest { + fn sandbox_retry_data(&self) -> Option { + None + } +} + #[derive(Default)] pub struct ApplyPatchRuntime; @@ -106,9 +114,10 @@ impl Approvable for ApplyPatchRuntime { let call_id = ctx.call_id.to_string(); let cwd = req.cwd.clone(); let retry_reason = ctx.retry_reason.clone(); + let risk = ctx.risk.clone(); let user_explicitly_approved = req.user_explicitly_approved; Box::pin(async move { - with_cached_approval(&session.services, key, || async move { + with_cached_approval(&session.services, key, move || async move { if let Some(reason) = retry_reason { session .request_command_approval( @@ -117,6 +126,7 @@ impl Approvable for ApplyPatchRuntime { vec!["apply_patch".to_string()], cwd, Some(reason), + risk, ) .await } else if user_explicitly_approved { diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index bfc2114fb3..f29224fcc1 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -12,7 +12,9 @@ use crate::sandboxing::execute_env; use crate::tools::runtimes::build_command_spec; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; +use crate::tools::sandboxing::ProvidesSandboxRetryData; use crate::tools::sandboxing::SandboxAttempt; +use crate::tools::sandboxing::SandboxRetryData; use crate::tools::sandboxing::Sandboxable; use crate::tools::sandboxing::SandboxablePreference; use crate::tools::sandboxing::ToolCtx; @@ -34,6 +36,15 @@ pub struct ShellRequest { pub justification: Option, } +impl ProvidesSandboxRetryData for ShellRequest { + fn sandbox_retry_data(&self) -> Option { + Some(SandboxRetryData { + command: self.command.clone(), + cwd: self.cwd.clone(), + }) + } +} + #[derive(Default)] pub struct ShellRuntime; @@ -90,13 +101,14 @@ impl Approvable for ShellRuntime { .retry_reason .clone() .or_else(|| req.justification.clone()); + let risk = ctx.risk.clone(); let session = ctx.session; let turn = ctx.turn; let call_id = ctx.call_id.to_string(); Box::pin(async move { - with_cached_approval(&session.services, key, || async move { + with_cached_approval(&session.services, key, move || async move { session - .request_command_approval(turn, call_id, command, cwd, reason) + .request_command_approval(turn, call_id, command, cwd, reason, risk) .await }) .await diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index c7d136ebfd..85c996387a 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -9,7 +9,9 @@ use crate::error::SandboxErr; use crate::tools::runtimes::build_command_spec; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; +use crate::tools::sandboxing::ProvidesSandboxRetryData; use crate::tools::sandboxing::SandboxAttempt; +use crate::tools::sandboxing::SandboxRetryData; use crate::tools::sandboxing::Sandboxable; use crate::tools::sandboxing::SandboxablePreference; use crate::tools::sandboxing::ToolCtx; @@ -31,6 +33,15 @@ pub struct UnifiedExecRequest { pub env: HashMap, } +impl ProvidesSandboxRetryData for UnifiedExecRequest { + fn sandbox_retry_data(&self) -> Option { + Some(SandboxRetryData { + command: self.command.clone(), + cwd: self.cwd.clone(), + }) + } +} + #[derive(serde::Serialize, Clone, Debug, Eq, PartialEq, Hash)] pub struct UnifiedExecApprovalKey { pub command: Vec, @@ -85,10 +96,11 @@ impl Approvable for UnifiedExecRuntime<'_> { let command = req.command.clone(); let cwd = req.cwd.clone(); let reason = ctx.retry_reason.clone(); + let risk = ctx.risk.clone(); Box::pin(async move { with_cached_approval(&session.services, key, || async move { session - .request_command_approval(turn, call_id, command, cwd, reason) + .request_command_approval(turn, call_id, command, cwd, reason, risk) .await }) .await diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 0dd7a99f47..ad483e2f93 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -7,6 +7,7 @@ use crate::codex::Session; use crate::codex::TurnContext; use crate::error::CodexErr; +use crate::protocol::SandboxCommandAssessment; use crate::protocol::SandboxPolicy; use crate::sandboxing::CommandSpec; use crate::sandboxing::SandboxManager; @@ -18,6 +19,7 @@ use std::collections::HashMap; use std::fmt::Debug; use std::hash::Hash; use std::path::Path; +use std::path::PathBuf; use futures::Future; use futures::future::BoxFuture; @@ -81,6 +83,7 @@ pub(crate) struct ApprovalCtx<'a> { pub turn: &'a TurnContext, pub call_id: &'a str, pub retry_reason: Option, + pub risk: Option, } pub(crate) trait Approvable { @@ -156,6 +159,17 @@ pub(crate) struct ToolCtx<'a> { pub tool_name: String, } +/// Captures the command metadata needed to re-run a tool request without sandboxing. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SandboxRetryData { + pub command: Vec, + pub cwd: PathBuf, +} + +pub(crate) trait ProvidesSandboxRetryData { + fn sandbox_retry_data(&self) -> Option; +} + #[derive(Debug)] pub(crate) enum ToolError { Rejected(String), diff --git a/codex-rs/core/src/truncate.rs b/codex-rs/core/src/truncate.rs index ab0158720b..3f0be8fcaf 100644 --- a/codex-rs/core/src/truncate.rs +++ b/codex-rs/core/src/truncate.rs @@ -1,18 +1,35 @@ //! Utilities for truncating large chunks of output while preserving a prefix //! and suffix on UTF-8 boundaries. +use codex_utils_tokenizer::Tokenizer; + /// Truncate the middle of a UTF-8 string to at most `max_bytes` bytes, /// preserving the beginning and the end. Returns the possibly truncated -/// string and `Some(original_token_count)` (estimated at 4 bytes/token) +/// string and `Some(original_token_count)` (counted with the local tokenizer; +/// falls back to a 4-bytes-per-token estimate if the tokenizer cannot load) /// if truncation occurred; otherwise returns the original string and `None`. pub(crate) fn truncate_middle(s: &str, max_bytes: usize) -> (String, Option) { if s.len() <= max_bytes { return (s.to_string(), None); } - let est_tokens = (s.len() as u64).div_ceil(4); + // Build a tokenizer for counting (default to o200k_base; fall back to cl100k_base). + // If both fail, fall back to a 4-bytes-per-token estimate. + let tok = Tokenizer::try_default().ok(); + let token_count = |text: &str| -> u64 { + if let Some(ref t) = tok { + t.count(text) as u64 + } else { + (text.len() as u64).div_ceil(4) + } + }; + + let total_tokens = token_count(s); if max_bytes == 0 { - return (format!("…{est_tokens} tokens truncated…"), Some(est_tokens)); + return ( + format!("…{total_tokens} tokens truncated…"), + Some(total_tokens), + ); } fn truncate_on_boundary(input: &str, max_len: usize) -> &str { @@ -50,13 +67,17 @@ pub(crate) fn truncate_middle(s: &str, max_bytes: usize) -> (String, Option idx } - let mut guess_tokens = est_tokens; + // Iterate to stabilize marker length → keep budget → boundaries. + let mut guess_tokens: u64 = 1; for _ in 0..4 { let marker = format!("…{guess_tokens} tokens truncated…"); let marker_len = marker.len(); let keep_budget = max_bytes.saturating_sub(marker_len); if keep_budget == 0 { - return (format!("…{est_tokens} tokens truncated…"), Some(est_tokens)); + return ( + format!("…{total_tokens} tokens truncated…"), + Some(total_tokens), + ); } let left_budget = keep_budget / 2; @@ -67,59 +88,72 @@ pub(crate) fn truncate_middle(s: &str, max_bytes: usize) -> (String, Option suffix_start = prefix_end; } - let kept_content_bytes = prefix_end + (s.len() - suffix_start); - let truncated_content_bytes = s.len().saturating_sub(kept_content_bytes); - let new_tokens = (truncated_content_bytes as u64).div_ceil(4); + // Tokens actually removed (middle slice) using the real tokenizer. + let removed_tokens = token_count(&s[prefix_end..suffix_start]); - if new_tokens == guess_tokens { - let mut out = String::with_capacity(marker_len + kept_content_bytes + 1); + // If the number of digits in the token count does not change the marker length, + // we can finalize output. + let final_marker = format!("…{removed_tokens} tokens truncated…"); + if final_marker.len() == marker_len { + let kept_content_bytes = prefix_end + (s.len() - suffix_start); + let mut out = String::with_capacity(final_marker.len() + kept_content_bytes + 1); out.push_str(&s[..prefix_end]); - out.push_str(&marker); + out.push_str(&final_marker); out.push('\n'); out.push_str(&s[suffix_start..]); - return (out, Some(est_tokens)); + return (out, Some(total_tokens)); } - guess_tokens = new_tokens; + guess_tokens = removed_tokens; } + // Fallback build after iterations: compute with the last guess. let marker = format!("…{guess_tokens} tokens truncated…"); let marker_len = marker.len(); let keep_budget = max_bytes.saturating_sub(marker_len); if keep_budget == 0 { - return (format!("…{est_tokens} tokens truncated…"), Some(est_tokens)); + return ( + format!("…{total_tokens} tokens truncated…"), + Some(total_tokens), + ); } let left_budget = keep_budget / 2; let right_budget = keep_budget - left_budget; let prefix_end = pick_prefix_end(s, left_budget); - let suffix_start = pick_suffix_start(s, right_budget); + let mut suffix_start = pick_suffix_start(s, right_budget); + if suffix_start < prefix_end { + suffix_start = prefix_end; + } let mut out = String::with_capacity(marker_len + prefix_end + (s.len() - suffix_start) + 1); out.push_str(&s[..prefix_end]); out.push_str(&marker); out.push('\n'); out.push_str(&s[suffix_start..]); - (out, Some(est_tokens)) + (out, Some(total_tokens)) } #[cfg(test)] mod tests { use super::truncate_middle; + use codex_utils_tokenizer::Tokenizer; #[test] fn truncate_middle_no_newlines_fallback() { + let tok = Tokenizer::try_default().expect("load tokenizer"); let s = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ*"; let max_bytes = 32; let (out, original) = truncate_middle(s, max_bytes); assert!(out.starts_with("abc")); assert!(out.contains("tokens truncated")); assert!(out.ends_with("XYZ*")); - assert_eq!(original, Some((s.len() as u64).div_ceil(4))); + assert_eq!(original, Some(tok.count(s) as u64)); } #[test] fn truncate_middle_prefers_newline_boundaries() { + let tok = Tokenizer::try_default().expect("load tokenizer"); let mut s = String::new(); for i in 1..=20 { s.push_str(&format!("{i:03}\n")); @@ -131,50 +165,36 @@ mod tests { assert!(out.starts_with("001\n002\n003\n004\n")); assert!(out.contains("tokens truncated")); assert!(out.ends_with("017\n018\n019\n020\n")); - assert_eq!(tokens, Some(20)); + assert_eq!(tokens, Some(tok.count(&s) as u64)); } #[test] fn truncate_middle_handles_utf8_content() { + let tok = Tokenizer::try_default().expect("load tokenizer"); let s = "😀😀😀😀😀😀😀😀😀😀\nsecond line with ascii text\n"; let max_bytes = 32; let (out, tokens) = truncate_middle(s, max_bytes); assert!(out.contains("tokens truncated")); assert!(!out.contains('\u{fffd}')); - assert_eq!(tokens, Some((s.len() as u64).div_ceil(4))); + assert_eq!(tokens, Some(tok.count(s) as u64)); } #[test] fn truncate_middle_prefers_newline_boundaries_2() { + let tok = Tokenizer::try_default().expect("load tokenizer"); // Build a multi-line string of 20 numbered lines (each "NNN\n"). let mut s = String::new(); for i in 1..=20 { s.push_str(&format!("{i:03}\n")); } - // Total length: 20 lines * 4 bytes per line = 80 bytes. assert_eq!(s.len(), 80); - // Choose a cap that forces truncation while leaving room for - // a few lines on each side after accounting for the marker. let max_bytes = 64; - // Expect exact output: first 4 lines, marker, last 4 lines, and correct token estimate (80/4 = 20). - assert_eq!( - truncate_middle(&s, max_bytes), - ( - r#"001 -002 -003 -004 -…12 tokens truncated… -017 -018 -019 -020 -"# - .to_string(), - Some(20) - ) - ); + let (out, total) = truncate_middle(&s, max_bytes); + assert!(out.starts_with("001\n002\n003\n004\n")); + assert!(out.contains("tokens truncated")); + assert!(out.ends_with("017\n018\n019\n020\n")); + assert_eq!(total, Some(tok.count(&s) as u64)); } } diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index d575929d6e..a1e2c0c444 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -22,6 +22,8 @@ //! - `session_manager.rs`: orchestration (approvals, sandboxing, reuse) and request handling. use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; use std::sync::atomic::AtomicI32; use std::time::Duration; @@ -45,10 +47,20 @@ pub(crate) const MAX_YIELD_TIME_MS: u64 = 30_000; pub(crate) const DEFAULT_MAX_OUTPUT_TOKENS: usize = 10_000; pub(crate) const UNIFIED_EXEC_OUTPUT_MAX_BYTES: usize = 1024 * 1024; // 1 MiB -pub(crate) struct UnifiedExecContext<'a> { - pub session: &'a Session, - pub turn: &'a TurnContext, - pub call_id: &'a str, +pub(crate) struct UnifiedExecContext { + pub session: Arc, + pub turn: Arc, + pub call_id: String, +} + +impl UnifiedExecContext { + pub fn new(session: Arc, turn: Arc, call_id: String) -> Self { + Self { + session, + turn, + call_id, + } + } } #[derive(Debug)] @@ -70,6 +82,7 @@ pub(crate) struct WriteStdinRequest<'a> { #[derive(Debug, Clone, PartialEq)] pub(crate) struct UnifiedExecResponse { + pub event_call_id: String, pub chunk_id: String, pub wall_time: Duration, pub output: String, @@ -78,10 +91,20 @@ pub(crate) struct UnifiedExecResponse { pub original_token_count: Option, } -#[derive(Debug, Default)] +#[derive(Default)] pub(crate) struct UnifiedExecSessionManager { next_session_id: AtomicI32, - sessions: Mutex>, + sessions: Mutex>, +} + +struct SessionEntry { + session: session::UnifiedExecSession, + session_ref: Arc, + turn_ref: Arc, + call_id: String, + command: String, + cwd: PathBuf, + started_at: tokio::time::Instant, } pub(crate) fn clamp_yield_time(yield_time_ms: Option) -> u64 { @@ -163,11 +186,8 @@ mod tests { cmd: &str, yield_time_ms: Option, ) -> Result { - let context = UnifiedExecContext { - session, - turn: turn.as_ref(), - call_id: "call", - }; + let context = + UnifiedExecContext::new(Arc::clone(session), Arc::clone(turn), "call".to_string()); session .services diff --git a/codex-rs/core/src/unified_exec/session_manager.rs b/codex-rs/core/src/unified_exec/session_manager.rs index 77124a7d21..7f4727d0c9 100644 --- a/codex-rs/core/src/unified_exec/session_manager.rs +++ b/codex-rs/core/src/unified_exec/session_manager.rs @@ -5,8 +5,13 @@ use tokio::sync::mpsc; use tokio::time::Duration; use tokio::time::Instant; +use crate::exec::ExecToolCallOutput; +use crate::exec::StreamOutput; use crate::exec_env::create_env; use crate::sandboxing::ExecEnv; +use crate::tools::events::ToolEmitter; +use crate::tools::events::ToolEventCtx; +use crate::tools::events::ToolEventStage; use crate::tools::orchestrator::ToolOrchestrator; use crate::tools::runtimes::unified_exec::UnifiedExecRequest as UnifiedExecToolRequest; use crate::tools::runtimes::unified_exec::UnifiedExecRuntime; @@ -14,6 +19,7 @@ use crate::tools::sandboxing::ToolCtx; use super::ExecCommandRequest; use super::MIN_YIELD_TIME_MS; +use super::SessionEntry; use super::UnifiedExecContext; use super::UnifiedExecError; use super::UnifiedExecResponse; @@ -30,7 +36,7 @@ impl UnifiedExecSessionManager { pub(crate) async fn exec_command( &self, request: ExecCommandRequest<'_>, - context: &UnifiedExecContext<'_>, + context: &UnifiedExecContext, ) -> Result { let shell_flag = if request.login { "-lc" } else { "-c" }; let command = vec![ @@ -59,17 +65,36 @@ impl UnifiedExecSessionManager { let session_id = if session.has_exited() { None } else { - Some(self.store_session(session).await) + Some( + self.store_session(session, context, request.command, start) + .await, + ) }; - Ok(UnifiedExecResponse { + let response = UnifiedExecResponse { + event_call_id: context.call_id.clone(), chunk_id, wall_time, output, session_id, exit_code, original_token_count, - }) + }; + + // If the command completed during this call, emit an ExecCommandEnd via the emitter. + if response.session_id.is_none() { + let exit = response.exit_code.unwrap_or(-1); + Self::emit_exec_end_from_context( + context, + request.command.to_string(), + response.output.clone(), + exit, + response.wall_time, + ) + .await; + } + + Ok(response) } pub(crate) async fn write_stdin( @@ -98,37 +123,60 @@ impl UnifiedExecSessionManager { let (output, original_token_count) = truncate_output_to_tokens(&text, max_tokens); let chunk_id = generate_chunk_id(); - let (session_id, exit_code) = self.refresh_session_state(session_id).await; + let status = self.refresh_session_state(session_id).await; + let (session_id, exit_code, completion_entry, event_call_id) = match status { + SessionStatus::Alive { exit_code, call_id } => { + (Some(session_id), exit_code, None, call_id) + } + SessionStatus::Exited { exit_code, entry } => { + let call_id = entry.call_id.clone(); + (None, exit_code, Some(*entry), call_id) + } + SessionStatus::Unknown => { + return Err(UnifiedExecError::UnknownSessionId { session_id }); + } + }; - Ok(UnifiedExecResponse { + let response = UnifiedExecResponse { + event_call_id, chunk_id, wall_time, output, session_id, exit_code, original_token_count, - }) - } + }; - async fn refresh_session_state(&self, session_id: i32) -> (Option, Option) { - let mut sessions = self.sessions.lock().await; - if !sessions.contains_key(&session_id) { - return (None, None); + if let (Some(exit), Some(entry)) = (response.exit_code, completion_entry) { + let total_duration = Instant::now().saturating_duration_since(entry.started_at); + Self::emit_exec_end_from_entry(entry, response.output.clone(), exit, total_duration) + .await; } - let has_exited = sessions - .get(&session_id) - .map(UnifiedExecSession::has_exited) - .unwrap_or(false); - let exit_code = sessions - .get(&session_id) - .and_then(UnifiedExecSession::exit_code); + Ok(response) + } - if has_exited { - sessions.remove(&session_id); - (None, exit_code) + async fn refresh_session_state(&self, session_id: i32) -> SessionStatus { + let mut sessions = self.sessions.lock().await; + let Some(entry) = sessions.get(&session_id) else { + return SessionStatus::Unknown; + }; + + let exit_code = entry.session.exit_code(); + + if entry.session.has_exited() { + let Some(entry) = sessions.remove(&session_id) else { + return SessionStatus::Unknown; + }; + SessionStatus::Exited { + exit_code, + entry: Box::new(entry), + } } else { - (Some(session_id), exit_code) + SessionStatus::Alive { + exit_code, + call_id: entry.call_id.clone(), + } } } @@ -138,9 +186,9 @@ impl UnifiedExecSessionManager { ) -> Result<(mpsc::Sender>, OutputBuffer, Arc), UnifiedExecError> { let sessions = self.sessions.lock().await; let (output_buffer, output_notify, writer_tx) = - if let Some(session) = sessions.get(&session_id) { - let (buffer, notify) = session.output_handles(); - (buffer, notify, session.writer_sender()) + if let Some(entry) = sessions.get(&session_id) { + let (buffer, notify) = entry.session.output_handles(); + (buffer, notify, entry.session.writer_sender()) } else { return Err(UnifiedExecError::UnknownSessionId { session_id }); }; @@ -158,14 +206,82 @@ impl UnifiedExecSessionManager { .map_err(|_| UnifiedExecError::WriteToStdin) } - async fn store_session(&self, session: UnifiedExecSession) -> i32 { + async fn store_session( + &self, + session: UnifiedExecSession, + context: &UnifiedExecContext, + command: &str, + started_at: Instant, + ) -> i32 { let session_id = self .next_session_id .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - self.sessions.lock().await.insert(session_id, session); + let entry = SessionEntry { + session, + session_ref: Arc::clone(&context.session), + turn_ref: Arc::clone(&context.turn), + call_id: context.call_id.clone(), + command: command.to_string(), + cwd: context.turn.cwd.clone(), + started_at, + }; + self.sessions.lock().await.insert(session_id, entry); session_id } + async fn emit_exec_end_from_entry( + entry: SessionEntry, + aggregated_output: String, + exit_code: i32, + duration: Duration, + ) { + let output = ExecToolCallOutput { + exit_code, + stdout: StreamOutput::new(aggregated_output.clone()), + stderr: StreamOutput::new(String::new()), + aggregated_output: StreamOutput::new(aggregated_output), + duration, + timed_out: false, + }; + let event_ctx = ToolEventCtx::new( + entry.session_ref.as_ref(), + entry.turn_ref.as_ref(), + &entry.call_id, + None, + ); + let emitter = ToolEmitter::unified_exec(entry.command, entry.cwd, true); + emitter + .emit(event_ctx, ToolEventStage::Success(output)) + .await; + } + + async fn emit_exec_end_from_context( + context: &UnifiedExecContext, + command: String, + aggregated_output: String, + exit_code: i32, + duration: Duration, + ) { + let output = ExecToolCallOutput { + exit_code, + stdout: StreamOutput::new(aggregated_output.clone()), + stderr: StreamOutput::new(String::new()), + aggregated_output: StreamOutput::new(aggregated_output), + duration, + timed_out: false, + }; + let event_ctx = ToolEventCtx::new( + context.session.as_ref(), + context.turn.as_ref(), + &context.call_id, + None, + ); + let emitter = ToolEmitter::unified_exec(command, context.turn.cwd.clone(), true); + emitter + .emit(event_ctx, ToolEventStage::Success(output)) + .await; + } + pub(crate) async fn open_session_with_exec_env( &self, env: &ExecEnv, @@ -184,7 +300,7 @@ impl UnifiedExecSessionManager { pub(super) async fn open_session_with_sandbox( &self, command: Vec, - context: &UnifiedExecContext<'_>, + context: &UnifiedExecContext, ) -> Result { let mut orchestrator = ToolOrchestrator::new(); let mut runtime = UnifiedExecRuntime::new(self); @@ -194,9 +310,9 @@ impl UnifiedExecSessionManager { create_env(&context.turn.shell_environment_policy), ); let tool_ctx = ToolCtx { - session: context.session, - turn: context.turn, - call_id: context.call_id.to_string(), + session: context.session.as_ref(), + turn: context.turn.as_ref(), + call_id: context.call_id.clone(), tool_name: "exec_command".to_string(), }; orchestrator @@ -204,7 +320,7 @@ impl UnifiedExecSessionManager { &mut runtime, &req, &tool_ctx, - context.turn, + context.turn.as_ref(), context.turn.approval_policy, ) .await @@ -255,3 +371,15 @@ impl UnifiedExecSessionManager { collected } } + +enum SessionStatus { + Alive { + exit_code: Option, + call_id: String, + }, + Exited { + exit_code: Option, + entry: Box, + }, + Unknown, +} diff --git a/codex-rs/core/templates/sandboxing/assessment_prompt.md b/codex-rs/core/templates/sandboxing/assessment_prompt.md new file mode 100644 index 0000000000..7e5664a0ad --- /dev/null +++ b/codex-rs/core/templates/sandboxing/assessment_prompt.md @@ -0,0 +1,27 @@ +You are a security analyst evaluating shell commands that were blocked by a sandbox. Given the provided metadata, summarize the command's likely intent and assess the risk. Return strictly valid JSON with the keys: +- description (concise summary, at most two sentences) +- risk_level ("low", "medium", or "high") +- risk_categories (optional array of zero or more category strings) +Risk level examples: +- low: read-only inspections, listing files, printing configuration +- medium: modifying project files, installing dependencies, fetching artifacts from trusted sources +- high: deleting or overwriting data, exfiltrating secrets, escalating privileges, or disabling security controls +Recognized risk_categories: data_deletion, data_exfiltration, privilege_escalation, system_modification, network_access, resource_exhaustion, compliance. +Use multiple categories when appropriate. +If information is insufficient, choose the most cautious risk level supported by the evidence. +Respond with JSON only, without markdown code fences or extra commentary. + +--- + +Command metadata: +Platform: {{ platform }} +Sandbox policy: {{ sandbox_policy }} +{% if let Some(roots) = filesystem_roots %} +Filesystem roots: {{ roots }} +{% endif %} +Working directory: {{ working_directory }} +Command argv: {{ command_argv }} +Command (joined): {{ command_joined }} +{% if let Some(message) = sandbox_failure_message %} +Sandbox failure message: {{ message }} +{% endif %} diff --git a/codex-rs/core/tests/common/Cargo.toml b/codex-rs/core/tests/common/Cargo.toml index b3082dc548..65abe23c63 100644 --- a/codex-rs/core/tests/common/Cargo.toml +++ b/codex-rs/core/tests/common/Cargo.toml @@ -10,6 +10,7 @@ path = "lib.rs" anyhow = { workspace = true } assert_cmd = { workspace = true } codex-core = { workspace = true } +codex-protocol = { workspace = true } notify = { workspace = true } regex-lite = { workspace = true } serde_json = { workspace = true } diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 102be3538f..511c0b5b03 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -35,6 +35,22 @@ impl ResponseMock { pub fn requests(&self) -> Vec { self.requests.lock().unwrap().clone() } + + /// Returns true if any captured request contains a `function_call` with the + /// provided `call_id`. + pub fn saw_function_call(&self, call_id: &str) -> bool { + self.requests() + .iter() + .any(|req| req.has_function_call(call_id)) + } + + /// Returns the `output` string for a matching `function_call_output` with + /// the provided `call_id`, searching across all captured requests. + pub fn function_call_output_text(&self, call_id: &str) -> Option { + self.requests() + .iter() + .find_map(|req| req.function_call_output_text(call_id)) + } } #[derive(Debug, Clone)] @@ -70,6 +86,28 @@ impl ResponsesRequest { .unwrap_or_else(|| panic!("function call output {call_id} item not found in request")) } + /// Returns true if this request's `input` contains a `function_call` with + /// the specified `call_id`. + pub fn has_function_call(&self, call_id: &str) -> bool { + self.input().iter().any(|item| { + item.get("type").and_then(Value::as_str) == Some("function_call") + && item.get("call_id").and_then(Value::as_str) == Some(call_id) + }) + } + + /// If present, returns the `output` string of the `function_call_output` + /// entry matching `call_id` in this request's `input`. + pub fn function_call_output_text(&self, call_id: &str) -> Option { + let binding = self.input(); + let item = binding.iter().find(|item| { + item.get("type").and_then(Value::as_str) == Some("function_call_output") + && item.get("call_id").and_then(Value::as_str) == Some(call_id) + })?; + item.get("output") + .and_then(Value::as_str) + .map(str::to_string) + } + pub fn header(&self, name: &str) -> Option { self.0 .headers diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 56f57a5e2b..e12f7e9b5e 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -1,17 +1,30 @@ use std::mem::swap; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; +use anyhow::Result; use codex_core::CodexAuth; use codex_core::CodexConversation; use codex_core::ConversationManager; use codex_core::ModelProviderInfo; use codex_core::built_in_model_providers; use codex_core::config::Config; +use codex_core::features::Feature; +use codex_core::protocol::AskForApproval; +use codex_core::protocol::EventMsg; +use codex_core::protocol::Op; +use codex_core::protocol::SandboxPolicy; use codex_core::protocol::SessionConfiguredEvent; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::user_input::UserInput; +use serde_json::Value; use tempfile::TempDir; +use wiremock::MockServer; use crate::load_default_config_for_test; +use crate::responses::start_mock_server; +use crate::wait_for_event; type ConfigMutator = dyn FnOnce(&mut Config) + Send; @@ -96,6 +109,12 @@ impl TestCodexBuilder { mutator(&mut config); } + if config.include_apply_patch_tool { + config.features.enable(Feature::ApplyPatchFreeform); + } else { + config.features.disable(Feature::ApplyPatchFreeform); + } + Ok((config, cwd)) } } @@ -107,6 +126,139 @@ pub struct TestCodex { pub session_configured: SessionConfiguredEvent, } +impl TestCodex { + pub fn cwd_path(&self) -> &Path { + self.cwd.path() + } + + pub fn workspace_path(&self, rel: impl AsRef) -> PathBuf { + self.cwd_path().join(rel) + } + + pub async fn submit_turn(&self, prompt: &str) -> Result<()> { + self.submit_turn_with_policy(prompt, SandboxPolicy::DangerFullAccess) + .await + } + + pub async fn submit_turn_with_policy( + &self, + prompt: &str, + sandbox_policy: SandboxPolicy, + ) -> Result<()> { + let session_model = self.session_configured.model.clone(); + self.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: prompt.into(), + }], + final_output_json_schema: None, + cwd: self.cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + wait_for_event(&self.codex, |event| { + matches!(event, EventMsg::TaskComplete(_)) + }) + .await; + Ok(()) + } +} + +pub struct TestCodexHarness { + server: MockServer, + test: TestCodex, +} + +impl TestCodexHarness { + pub async fn new() -> Result { + Self::with_builder(test_codex()).await + } + + pub async fn with_config(mutator: impl FnOnce(&mut Config) + Send + 'static) -> Result { + Self::with_builder(test_codex().with_config(mutator)).await + } + + pub async fn with_builder(mut builder: TestCodexBuilder) -> Result { + let server = start_mock_server().await; + let test = builder.build(&server).await?; + Ok(Self { server, test }) + } + + pub fn server(&self) -> &MockServer { + &self.server + } + + pub fn test(&self) -> &TestCodex { + &self.test + } + + pub fn cwd(&self) -> &Path { + self.test.cwd_path() + } + + pub fn path(&self, rel: impl AsRef) -> PathBuf { + self.test.workspace_path(rel) + } + + pub async fn submit(&self, prompt: &str) -> Result<()> { + self.test.submit_turn(prompt).await + } + + pub async fn submit_with_policy( + &self, + prompt: &str, + sandbox_policy: SandboxPolicy, + ) -> Result<()> { + self.test + .submit_turn_with_policy(prompt, sandbox_policy) + .await + } + + pub async fn request_bodies(&self) -> Vec { + self.server + .received_requests() + .await + .expect("requests") + .into_iter() + .map(|req| serde_json::from_slice(&req.body).expect("request body json")) + .collect() + } + + pub async fn function_call_output_value(&self, call_id: &str) -> Value { + let bodies = self.request_bodies().await; + function_call_output(&bodies, call_id).clone() + } + + pub async fn function_call_stdout(&self, call_id: &str) -> String { + self.function_call_output_value(call_id) + .await + .get("output") + .and_then(Value::as_str) + .expect("output string") + .to_string() + } +} + +fn function_call_output<'a>(bodies: &'a [Value], call_id: &str) -> &'a Value { + for body in bodies { + if let Some(items) = body.get("input").and_then(Value::as_array) { + for item in items { + if item.get("type").and_then(Value::as_str) == Some("function_call_output") + && item.get("call_id").and_then(Value::as_str) == Some(call_id) + { + return item; + } + } + } + } + panic!("function_call_output {call_id} not found"); +} + pub fn test_codex() -> TestCodexBuilder { TestCodexBuilder { config_mutators: vec![], diff --git a/codex-rs/core/tests/suite/abort_tasks.rs b/codex-rs/core/tests/suite/abort_tasks.rs index dcc65fc742..c9d595088a 100644 --- a/codex-rs/core/tests/suite/abort_tasks.rs +++ b/codex-rs/core/tests/suite/abort_tasks.rs @@ -1,3 +1,4 @@ +use std::sync::Arc; use std::time::Duration; use codex_core::protocol::EventMsg; @@ -5,7 +6,9 @@ use codex_core::protocol::Op; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; +use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::test_codex::test_codex; @@ -67,3 +70,98 @@ async fn interrupt_long_running_tool_emits_turn_aborted() { ) .await; } + +/// After an interrupt we expect the next request to the model to include both +/// the original tool call and an `"aborted"` `function_call_output`. This test +/// exercises the follow-up flow: it sends another user turn, inspects the mock +/// responses server, and ensures the model receives the synthesized abort. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn interrupt_tool_records_history_entries() { + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "sleep 60".to_string(), + ]; + let call_id = "call-history"; + + let args = json!({ + "command": command, + "timeout_ms": 60_000 + }) + .to_string(); + let first_body = sse(vec![ + ev_response_created("resp-history"), + ev_function_call(call_id, "shell", &args), + ev_completed("resp-history"), + ]); + let follow_up_body = sse(vec![ + ev_response_created("resp-followup"), + ev_completed("resp-followup"), + ]); + + let server = start_mock_server().await; + let response_mock = mount_sse_sequence(&server, vec![first_body, follow_up_body]).await; + + let fixture = test_codex().build(&server).await.unwrap(); + let codex = Arc::clone(&fixture.codex); + + let wait_timeout = Duration::from_millis(100); + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "start history recording".into(), + }], + }) + .await + .unwrap(); + + wait_for_event_with_timeout( + &codex, + |ev| matches!(ev, EventMsg::ExecCommandBegin(_)), + wait_timeout, + ) + .await; + + codex.submit(Op::Interrupt).await.unwrap(); + + wait_for_event_with_timeout( + &codex, + |ev| matches!(ev, EventMsg::TurnAborted(_)), + wait_timeout, + ) + .await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "follow up".into(), + }], + }) + .await + .unwrap(); + + wait_for_event_with_timeout( + &codex, + |ev| matches!(ev, EventMsg::TaskComplete(_)), + wait_timeout, + ) + .await; + + let requests = response_mock.requests(); + assert!( + requests.len() == 2, + "expected two calls to the responses API, got {}", + requests.len() + ); + + assert!( + response_mock.saw_function_call(call_id), + "function call not recorded in responses payload" + ); + assert_eq!( + response_mock.function_call_output_text(call_id).as_deref(), + Some("aborted"), + "aborted function call output not recorded in responses payload" + ); +} diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs new file mode 100644 index 0000000000..6e9761ae4a --- /dev/null +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -0,0 +1,1052 @@ +#![allow(clippy::expect_used)] + +use anyhow::Result; +use pretty_assertions::assert_eq; +use std::fs; + +use codex_core::config::Config; +use codex_core::features::Feature; +use codex_core::model_family::find_family_for_model; +use codex_core::protocol::AskForApproval; +use codex_core::protocol::EventMsg; +use codex_core::protocol::Op; +use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::user_input::UserInput; +use core_test_support::assert_regex_match; +use core_test_support::responses::ev_apply_patch_function_call; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::TestCodexHarness; +use core_test_support::wait_for_event; +use serde_json::json; + +async fn apply_patch_harness() -> Result { + apply_patch_harness_with(|_| {}).await +} + +async fn apply_patch_harness_with( + configure: impl FnOnce(&mut Config) + Send + 'static, +) -> Result { + TestCodexHarness::with_config(|config| { + config.include_apply_patch_tool = true; + configure(config); + }) + .await +} + +async fn mount_apply_patch( + harness: &TestCodexHarness, + call_id: &str, + patch: &str, + assistant_msg: &str, +) { + mount_sse_sequence( + harness.server(), + apply_patch_responses(call_id, patch, assistant_msg), + ) + .await; +} + +fn apply_patch_responses(call_id: &str, patch: &str, assistant_msg: &str) -> Vec { + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_apply_patch_function_call(call_id, patch), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", assistant_msg), + ev_completed("resp-2"), + ]), + ] +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_cli_multiple_operations_integration() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = apply_patch_harness_with(|config| { + config.model = "gpt-5".to_string(); + config.model_family = find_family_for_model("gpt-5").expect("gpt-5 is valid"); + }) + .await?; + + // Seed workspace state + let modify_path = harness.path("modify.txt"); + let delete_path = harness.path("delete.txt"); + fs::write(&modify_path, "line1\nline2\n")?; + fs::write(&delete_path, "obsolete\n")?; + + let patch = "*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Delete File: delete.txt\n*** Update File: modify.txt\n@@\n-line2\n+changed\n*** End Patch"; + + let call_id = "apply-multi-ops"; + mount_apply_patch(&harness, call_id, patch, "done").await; + + harness.submit("please apply multi-ops patch").await?; + + let out = harness.function_call_stdout(call_id).await; + + let expected = r"(?s)^Exit code: 0 +Wall time: [0-9]+(?:\.[0-9]+)? seconds +Output: +Success. Updated the following files: +A nested/new.txt +M modify.txt +D delete.txt +?$"; + assert_regex_match(expected, &out); + + assert_eq!( + fs::read_to_string(harness.path("nested/new.txt"))?, + "created\n" + ); + assert_eq!(fs::read_to_string(&modify_path)?, "line1\nchanged\n"); + assert!(!delete_path.exists()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_cli_multiple_chunks() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = apply_patch_harness().await?; + + let target = harness.path("multi.txt"); + fs::write(&target, "line1\nline2\nline3\nline4\n")?; + + let patch = "*** Begin Patch\n*** Update File: multi.txt\n@@\n-line2\n+changed2\n@@\n-line4\n+changed4\n*** End Patch"; + let call_id = "apply-multi-chunks"; + mount_apply_patch(&harness, call_id, patch, "ok").await; + + harness.submit("apply multi-chunk patch").await?; + + assert_eq!( + fs::read_to_string(&target)?, + "line1\nchanged2\nline3\nchanged4\n" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_cli_moves_file_to_new_directory() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = apply_patch_harness().await?; + + let original = harness.path("old/name.txt"); + let new_path = harness.path("renamed/dir/name.txt"); + fs::create_dir_all(original.parent().expect("parent"))?; + fs::write(&original, "old content\n")?; + + let patch = "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch"; + let call_id = "apply-move"; + mount_apply_patch(&harness, call_id, patch, "ok").await; + + harness.submit("apply move patch").await?; + + assert!(!original.exists()); + assert_eq!(fs::read_to_string(&new_path)?, "new content\n"); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_cli_updates_file_appends_trailing_newline() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = apply_patch_harness().await?; + + let target = harness.path("no_newline.txt"); + fs::write(&target, "no newline at end")?; + + let patch = "*** Begin Patch\n*** Update File: no_newline.txt\n@@\n-no newline at end\n+first line\n+second line\n*** End Patch"; + let call_id = "apply-append-nl"; + mount_apply_patch(&harness, call_id, patch, "ok").await; + + harness.submit("apply newline patch").await?; + + let contents = fs::read_to_string(&target)?; + assert!(contents.ends_with('\n')); + assert_eq!(contents, "first line\nsecond line\n"); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_cli_insert_only_hunk_modifies_file() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = apply_patch_harness().await?; + + let target = harness.path("insert_only.txt"); + fs::write(&target, "alpha\nomega\n")?; + + let patch = "*** Begin Patch\n*** Update File: insert_only.txt\n@@\n alpha\n+beta\n omega\n*** End Patch"; + let call_id = "apply-insert-only"; + mount_apply_patch(&harness, call_id, patch, "ok").await; + + harness.submit("insert lines via apply_patch").await?; + + assert_eq!(fs::read_to_string(&target)?, "alpha\nbeta\nomega\n"); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_cli_move_overwrites_existing_destination() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = apply_patch_harness().await?; + + let original = harness.path("old/name.txt"); + let destination = harness.path("renamed/dir/name.txt"); + fs::create_dir_all(original.parent().expect("parent"))?; + fs::create_dir_all(destination.parent().expect("parent"))?; + fs::write(&original, "from\n")?; + fs::write(&destination, "existing\n")?; + + let patch = "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-from\n+new\n*** End Patch"; + let call_id = "apply-move-overwrite"; + mount_apply_patch(&harness, call_id, patch, "ok").await; + + harness.submit("apply move overwrite patch").await?; + + assert!(!original.exists()); + assert_eq!(fs::read_to_string(&destination)?, "new\n"); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_cli_move_without_content_change_has_no_turn_diff() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = apply_patch_harness().await?; + let test = harness.test(); + let codex = test.codex.clone(); + let cwd = test.cwd.clone(); + + let original = harness.path("old/name.txt"); + let destination = harness.path("renamed/name.txt"); + fs::create_dir_all(original.parent().expect("parent should exist"))?; + fs::write(&original, "same\n")?; + + let patch = "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/name.txt\n@@\n same\n*** End Patch"; + let call_id = "apply-move-no-change"; + mount_apply_patch(&harness, call_id, patch, "ok").await; + + let model = test.session_configured.model.clone(); + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "rename without content change".into(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + let mut saw_turn_diff = false; + wait_for_event(&codex, |event| match event { + EventMsg::TurnDiff(_) => { + saw_turn_diff = true; + false + } + EventMsg::TaskComplete(_) => true, + _ => false, + }) + .await; + + assert!(!saw_turn_diff, "pure rename should not emit a turn diff"); + assert!(!original.exists()); + assert_eq!(fs::read_to_string(&destination)?, "same\n"); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_cli_add_overwrites_existing_file() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = apply_patch_harness().await?; + + let path = harness.path("duplicate.txt"); + fs::write(&path, "old content\n")?; + + let patch = "*** Begin Patch\n*** Add File: duplicate.txt\n+new content\n*** End Patch"; + let call_id = "apply-add-overwrite"; + mount_apply_patch(&harness, call_id, patch, "ok").await; + + harness.submit("apply add overwrite patch").await?; + + assert_eq!(fs::read_to_string(&path)?, "new content\n"); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_cli_rejects_invalid_hunk_header() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = apply_patch_harness().await?; + + let patch = "*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch"; + let call_id = "apply-invalid-header"; + mount_apply_patch(&harness, call_id, patch, "ok").await; + + harness.submit("apply invalid header patch").await?; + + let out = harness.function_call_stdout(call_id).await; + + assert!( + out.contains("apply_patch verification failed"), + "expected verification failure message" + ); + assert!( + out.contains("is not a valid hunk header"), + "expected parse diagnostics in output: {out:?}" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_cli_reports_missing_context() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = apply_patch_harness().await?; + + let target = harness.path("modify.txt"); + fs::write(&target, "line1\nline2\n")?; + + let patch = + "*** Begin Patch\n*** Update File: modify.txt\n@@\n-missing\n+changed\n*** End Patch"; + let call_id = "apply-missing-context"; + mount_apply_patch(&harness, call_id, patch, "ok").await; + + harness.submit("apply missing context patch").await?; + + let out = harness.function_call_stdout(call_id).await; + + assert!( + out.contains("apply_patch verification failed"), + "expected verification failure message" + ); + assert!(out.contains("Failed to find expected lines in")); + assert_eq!(fs::read_to_string(&target)?, "line1\nline2\n"); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_cli_reports_missing_target_file() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = apply_patch_harness().await?; + + let patch = "*** Begin Patch\n*** Update File: missing.txt\n@@\n-nope\n+better\n*** End Patch"; + let call_id = "apply-missing-file"; + mount_apply_patch(&harness, call_id, patch, "fail").await; + + harness.submit("attempt to update a missing file").await?; + + let out = harness.function_call_stdout(call_id).await; + assert!( + out.contains("apply_patch verification failed"), + "expected verification failure message" + ); + assert!( + out.contains("Failed to read file to update"), + "expected missing file diagnostics: {out}" + ); + assert!( + out.contains("missing.txt"), + "expected missing file path in diagnostics: {out}" + ); + assert!(!harness.path("missing.txt").exists()); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_cli_delete_missing_file_reports_error() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = apply_patch_harness().await?; + + let patch = "*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch"; + let call_id = "apply-delete-missing"; + mount_apply_patch(&harness, call_id, patch, "fail").await; + + harness.submit("attempt to delete missing file").await?; + + let out = harness.function_call_stdout(call_id).await; + + assert!( + out.contains("apply_patch verification failed"), + "expected verification failure message: {out}" + ); + assert!( + out.contains("Failed to read"), + "missing delete diagnostics should mention read failure: {out}" + ); + assert!( + out.contains("missing.txt"), + "missing delete diagnostics should surface target path: {out}" + ); + assert!(!harness.path("missing.txt").exists()); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_cli_rejects_empty_patch() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = apply_patch_harness().await?; + + let patch = "*** Begin Patch\n*** End Patch"; + let call_id = "apply-empty"; + mount_apply_patch(&harness, call_id, patch, "ok").await; + + harness.submit("apply empty patch").await?; + + let out = harness.function_call_stdout(call_id).await; + assert!( + out.contains("patch rejected: empty patch"), + "expected rejection for empty patch: {out}" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_cli_delete_directory_reports_verification_error() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = apply_patch_harness().await?; + + fs::create_dir(harness.path("dir"))?; + + let patch = "*** Begin Patch\n*** Delete File: dir\n*** End Patch"; + let call_id = "apply-delete-dir"; + mount_apply_patch(&harness, call_id, patch, "ok").await; + + harness.submit("delete a directory via apply_patch").await?; + + let out = harness.function_call_stdout(call_id).await; + assert!(out.contains("apply_patch verification failed")); + assert!(out.contains("Failed to read")); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_cli_rejects_path_traversal_outside_workspace() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = apply_patch_harness().await?; + + let escape_path = harness + .test() + .cwd + .path() + .parent() + .expect("cwd should have parent") + .join("escape.txt"); + let _ = fs::remove_file(&escape_path); + + let patch = "*** Begin Patch\n*** Add File: ../escape.txt\n+outside\n*** End Patch"; + let call_id = "apply-path-traversal"; + mount_apply_patch(&harness, call_id, patch, "fail").await; + + let sandbox_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + harness + .submit_with_policy( + "attempt to escape workspace via apply_patch", + sandbox_policy, + ) + .await?; + + let out = harness.function_call_stdout(call_id).await; + assert!( + out.contains( + "patch rejected: writing outside of the project; rejected by user approval settings" + ), + "expected rejection message for path traversal: {out}" + ); + assert!( + !escape_path.exists(), + "path traversal should be rejected; tool output: {out}" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_cli_rejects_move_path_traversal_outside_workspace() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = apply_patch_harness().await?; + + let escape_path = harness + .test() + .cwd + .path() + .parent() + .expect("cwd should have parent") + .join("escape-move.txt"); + let _ = fs::remove_file(&escape_path); + + let source = harness.path("stay.txt"); + fs::write(&source, "from\n")?; + + let patch = "*** Begin Patch\n*** Update File: stay.txt\n*** Move to: ../escape-move.txt\n@@\n-from\n+to\n*** End Patch"; + let call_id = "apply-move-traversal"; + mount_apply_patch(&harness, call_id, patch, "fail").await; + + let sandbox_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + harness + .submit_with_policy("attempt move traversal via apply_patch", sandbox_policy) + .await?; + + let out = harness.function_call_stdout(call_id).await; + assert!( + out.contains( + "patch rejected: writing outside of the project; rejected by user approval settings" + ), + "expected rejection message for path traversal: {out}" + ); + assert!( + !escape_path.exists(), + "move path traversal should be rejected; tool output: {out}" + ); + assert_eq!(fs::read_to_string(&source)?, "from\n"); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_cli_verification_failure_has_no_side_effects() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = apply_patch_harness_with(|config| { + config.features.enable(Feature::ApplyPatchFreeform); + }) + .await?; + + // Compose a patch that would create a file, then fail verification on an update. + let call_id = "apply-partial-no-side-effects"; + let patch = "*** Begin Patch\n*** Add File: created.txt\n+hello\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch"; + + mount_apply_patch(&harness, call_id, patch, "failed").await; + + harness.submit("attempt partial apply patch").await?; + + let created = harness.path("created.txt"); + assert!( + !created.exists(), + "verification failure should prevent any filesystem changes" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_shell_heredoc_with_cd_updates_relative_workdir() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = apply_patch_harness_with(|config| { + config.model = "gpt-5".to_string(); + config.model_family = find_family_for_model("gpt-5").expect("gpt-5 is valid"); + }) + .await?; + + // Prepare a file inside a subdir; update it via cd && apply_patch heredoc form. + let sub = harness.path("sub"); + fs::create_dir_all(&sub)?; + let target = sub.join("in_sub.txt"); + fs::write(&target, "before\n")?; + + let script = "cd sub && apply_patch <<'EOF'\n*** Begin Patch\n*** Update File: in_sub.txt\n@@\n-before\n+after\n*** End Patch\nEOF\n"; + let call_id = "shell-heredoc-cd"; + let args = json!({ + "command": ["bash", "-lc", script], + "timeout_ms": 5_000, + }); + let bodies = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "ok"), + ev_completed("resp-2"), + ]), + ]; + mount_sse_sequence(harness.server(), bodies).await; + + harness.submit("apply via shell heredoc with cd").await?; + + let out = harness.function_call_stdout(call_id).await; + assert!( + out.contains("Success."), + "expected successful apply_patch invocation via shell: {out}" + ); + assert_eq!(fs::read_to_string(&target)?, "after\n"); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_shell_failure_propagates_error_and_skips_diff() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = apply_patch_harness_with(|config| { + config.model = "gpt-5".to_string(); + config.model_family = find_family_for_model("gpt-5").expect("gpt-5 is valid"); + }) + .await?; + let test = harness.test(); + let codex = test.codex.clone(); + let cwd = test.cwd.clone(); + + let target = cwd.path().join("invalid.txt"); + fs::write(&target, "ok\n")?; + + let script = "apply_patch <<'EOF'\n*** Begin Patch\n*** Update File: invalid.txt\n@@\n-nope\n+changed\n*** End Patch\nEOF\n"; + let call_id = "shell-apply-failure"; + let args = json!({ + "command": ["bash", "-lc", script], + "timeout_ms": 5_000, + }); + let bodies = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "fail"), + ev_completed("resp-2"), + ]), + ]; + mount_sse_sequence(harness.server(), bodies).await; + + let model = test.session_configured.model.clone(); + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "apply patch via shell".into(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + let mut saw_turn_diff = false; + wait_for_event(&codex, |event| match event { + EventMsg::TurnDiff(_) => { + saw_turn_diff = true; + false + } + EventMsg::TaskComplete(_) => true, + _ => false, + }) + .await; + + assert!( + !saw_turn_diff, + "turn diff should not be emitted when shell apply_patch fails verification" + ); + + let out = harness.function_call_stdout(call_id).await; + assert!( + out.contains("apply_patch verification failed"), + "expected verification failure message" + ); + assert!( + out.contains("Failed to find expected lines in"), + "expected failure diagnostics: {out}" + ); + assert!( + out.contains("invalid.txt"), + "expected file path in output: {out}" + ); + assert_eq!(fs::read_to_string(&target)?, "ok\n"); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_function_accepts_lenient_heredoc_wrapped_patch() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = apply_patch_harness().await?; + + let file_name = "lenient.txt"; + let patch_inner = + format!("*** Begin Patch\n*** Add File: {file_name}\n+lenient\n*** End Patch\n"); + let wrapped = format!("<<'EOF'\n{patch_inner}EOF\n"); + let call_id = "apply-lenient"; + mount_apply_patch(&harness, call_id, &wrapped, "ok").await; + + harness.submit("apply lenient heredoc patch").await?; + + let new_file = harness.path(file_name); + assert_eq!(fs::read_to_string(new_file)?, "lenient\n"); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_cli_end_of_file_anchor() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = apply_patch_harness().await?; + + let target = harness.path("tail.txt"); + fs::write(&target, "alpha\nlast\n")?; + + let patch = "*** Begin Patch\n*** Update File: tail.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch"; + let call_id = "apply-eof"; + mount_apply_patch(&harness, call_id, patch, "ok").await; + + harness.submit("apply EOF-anchored patch").await?; + assert_eq!(fs::read_to_string(&target)?, "alpha\nend\n"); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_cli_missing_second_chunk_context_rejected() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = apply_patch_harness().await?; + + let target = harness.path("two_chunks.txt"); + fs::write(&target, "a\nb\nc\nd\n")?; + + // First chunk has @@, second chunk intentionally omits @@ to trigger parse error. + let patch = + "*** Begin Patch\n*** Update File: two_chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch"; + let call_id = "apply-missing-ctx-2nd"; + mount_apply_patch(&harness, call_id, patch, "fail").await; + + harness.submit("apply missing context second chunk").await?; + + let out = harness.function_call_stdout(call_id).await; + assert!(out.contains("apply_patch verification failed")); + assert!( + out.contains("Failed to find expected lines in"), + "expected hunk context diagnostics: {out}" + ); + // Original file unchanged on failure + assert_eq!(fs::read_to_string(&target)?, "a\nb\nc\nd\n"); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_emits_turn_diff_event_with_unified_diff() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = apply_patch_harness().await?; + let test = harness.test(); + let codex = test.codex.clone(); + let cwd = test.cwd.clone(); + + let call_id = "apply-diff-event"; + let file = "udiff.txt"; + let patch = format!("*** Begin Patch\n*** Add File: {file}\n+hello\n*** End Patch\n"); + let first = sse(vec![ + ev_response_created("resp-1"), + ev_apply_patch_function_call(call_id, &patch), + ev_completed("resp-1"), + ]); + let second = sse(vec![ + ev_assistant_message("msg-1", "ok"), + ev_completed("resp-2"), + ]); + mount_sse_sequence(harness.server(), vec![first, second]).await; + + let model = test.session_configured.model.clone(); + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "emit diff".into(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + let mut saw_turn_diff = None; + wait_for_event(&codex, |event| match event { + EventMsg::TurnDiff(ev) => { + saw_turn_diff = Some(ev.unified_diff.clone()); + false + } + EventMsg::TaskComplete(_) => true, + _ => false, + }) + .await; + + let diff = saw_turn_diff.expect("expected TurnDiff event"); + // Basic markers of a unified diff with file addition + assert!(diff.contains("diff --git"), "diff header missing: {diff:?}"); + assert!(diff.contains("--- /dev/null") || diff.contains("--- a/")); + assert!(diff.contains("+++ b/")); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_turn_diff_for_rename_with_content_change() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = apply_patch_harness().await?; + let test = harness.test(); + let codex = test.codex.clone(); + let cwd = test.cwd.clone(); + + // Seed original file + let old = cwd.path().join("old.txt"); + fs::write(&old, "old\n")?; + + // Patch: update + move + let call_id = "apply-rename-change"; + let patch = "*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n@@\n-old\n+new\n*** End Patch"; + let first = sse(vec![ + ev_response_created("resp-1"), + ev_apply_patch_function_call(call_id, patch), + ev_completed("resp-1"), + ]); + let second = sse(vec![ + ev_assistant_message("msg-1", "ok"), + ev_completed("resp-2"), + ]); + mount_sse_sequence(harness.server(), vec![first, second]).await; + + let model = test.session_configured.model.clone(); + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "rename with change".into(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + let mut last_diff: Option = None; + wait_for_event(&codex, |event| match event { + EventMsg::TurnDiff(ev) => { + last_diff = Some(ev.unified_diff.clone()); + false + } + EventMsg::TaskComplete(_) => true, + _ => false, + }) + .await; + + let diff = last_diff.expect("expected TurnDiff event after rename"); + // Basic checks: shows old -> new, and the content delta + assert!(diff.contains("old.txt"), "diff missing old path: {diff:?}"); + assert!(diff.contains("new.txt"), "diff missing new path: {diff:?}"); + assert!(diff.contains("--- a/"), "missing old header"); + assert!(diff.contains("+++ b/"), "missing new header"); + assert!(diff.contains("-old\n"), "missing removal line"); + assert!(diff.contains("+new\n"), "missing addition line"); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = apply_patch_harness().await?; + let test = harness.test(); + let codex = test.codex.clone(); + let cwd = test.cwd.clone(); + + let call1 = "agg-1"; + let call2 = "agg-2"; + let patch1 = "*** Begin Patch\n*** Add File: agg/a.txt\n+v1\n*** End Patch"; + let patch2 = "*** Begin Patch\n*** Update File: agg/a.txt\n@@\n-v1\n+v2\n*** Add File: agg/b.txt\n+B\n*** End Patch"; + + let s1 = sse(vec![ + ev_response_created("resp-1"), + ev_apply_patch_function_call(call1, patch1), + ev_completed("resp-1"), + ]); + let s2 = sse(vec![ + ev_response_created("resp-2"), + ev_apply_patch_function_call(call2, patch2), + ev_completed("resp-2"), + ]); + let s3 = sse(vec![ + ev_assistant_message("msg-1", "ok"), + ev_completed("resp-3"), + ]); + mount_sse_sequence(harness.server(), vec![s1, s2, s3]).await; + + let model = test.session_configured.model.clone(); + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "aggregate diffs".into(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + let mut last_diff: Option = None; + wait_for_event(&codex, |event| match event { + EventMsg::TurnDiff(ev) => { + last_diff = Some(ev.unified_diff.clone()); + false + } + EventMsg::TaskComplete(_) => true, + _ => false, + }) + .await; + + let diff = last_diff.expect("expected TurnDiff after two patches"); + assert!(diff.contains("agg/a.txt"), "diff missing a.txt"); + assert!(diff.contains("agg/b.txt"), "diff missing b.txt"); + // Final content reflects v2 for a.txt + assert!(diff.contains("+v2\n") || diff.contains("v2\n")); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = apply_patch_harness().await?; + let test = harness.test(); + let codex = test.codex.clone(); + let cwd = test.cwd.clone(); + + let call_success = "agg-success"; + let call_failure = "agg-failure"; + let patch_success = "*** Begin Patch\n*** Add File: partial/success.txt\n+ok\n*** End Patch"; + let patch_failure = + "*** Begin Patch\n*** Update File: partial/success.txt\n@@\n-missing\n+new\n*** End Patch"; + + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_apply_patch_function_call(call_success, patch_success), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_apply_patch_function_call(call_failure, patch_failure), + ev_completed("resp-2"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "failed"), + ev_completed("resp-3"), + ]), + ]; + mount_sse_sequence(harness.server(), responses).await; + + let model = test.session_configured.model.clone(); + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "apply patch twice with failure".into(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + let mut last_diff: Option = None; + wait_for_event(&codex, |event| match event { + EventMsg::TurnDiff(ev) => { + last_diff = Some(ev.unified_diff.clone()); + false + } + EventMsg::TaskComplete(_) => true, + _ => false, + }) + .await; + + let diff = last_diff.expect("expected TurnDiff after failed patch"); + assert!( + diff.contains("partial/success.txt"), + "diff should still include the successful addition: {diff}" + ); + assert!( + diff.contains("+ok"), + "diff should include contents from successful patch: {diff}" + ); + + let failure_out = harness.function_call_stdout(call_failure).await; + assert!( + failure_out.contains("apply_patch verification failed"), + "expected verification failure output: {failure_out}" + ); + assert!( + failure_out.contains("Failed to find expected lines in"), + "expected missing context diagnostics: {failure_out}" + ); + + assert_eq!( + fs::read_to_string(cwd.path().join("partial/success.txt"))?, + "ok\n" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_change_context_disambiguates_target() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = apply_patch_harness().await?; + + let target = harness.path("multi_ctx.txt"); + fs::write(&target, "fn a\nx=10\ny=2\nfn b\nx=10\ny=20\n")?; + + let patch = + "*** Begin Patch\n*** Update File: multi_ctx.txt\n@@ fn b\n-x=10\n+x=11\n*** End Patch"; + let call_id = "apply-ctx"; + mount_apply_patch(&harness, call_id, patch, "ok").await; + + harness.submit("apply with change_context").await?; + + let contents = fs::read_to_string(&target)?; + assert_eq!(contents, "fn a\nx=10\ny=2\nfn b\nx=11\ny=20\n"); + Ok(()) +} diff --git a/codex-rs/core/tests/suite/cli_stream.rs b/codex-rs/core/tests/suite/cli_stream.rs index 497730926a..4c1f5fb8a8 100644 --- a/codex-rs/core/tests/suite/cli_stream.rs +++ b/codex-rs/core/tests/suite/cli_stream.rs @@ -75,9 +75,17 @@ async fn chat_mode_stream_cli() { server.verify().await; // Verify a new session rollout was created and is discoverable via list_conversations - let page = RolloutRecorder::list_conversations(home.path(), 10, None, &[]) - .await - .expect("list conversations"); + let provider_filter = vec!["mock".to_string()]; + let page = RolloutRecorder::list_conversations( + home.path(), + 10, + None, + &[], + Some(provider_filter.as_slice()), + "mock", + ) + .await + .expect("list conversations"); assert!( !page.items.is_empty(), "expected at least one session to be listed" diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 8730f1f38f..e302a1c114 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -154,7 +154,8 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { "instructions": "be nice", "cwd": ".", "originator": "test_originator", - "cli_version": "test_version" + "cli_version": "test_version", + "model_provider": "test-provider" } }) ) diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 4261a30510..b13c6e14fd 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -18,7 +18,6 @@ use codex_core::built_in_model_providers; use codex_core::codex::compact::SUMMARIZATION_PROMPT; use codex_core::config::Config; use codex_core::config::OPENAI_DEFAULT_MODEL; -use codex_core::protocol::ConversationPathResponseEvent; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; @@ -42,6 +41,29 @@ fn network_disabled() -> bool { std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() } +fn filter_out_ghost_snapshot_entries(items: &[Value]) -> Vec { + items + .iter() + .filter(|item| !is_ghost_snapshot_message(item)) + .cloned() + .collect() +} + +fn is_ghost_snapshot_message(item: &Value) -> bool { + if item.get("type").and_then(Value::as_str) != Some("message") { + return false; + } + if item.get("role").and_then(Value::as_str) != Some("user") { + return false; + } + item.get("content") + .and_then(Value::as_array) + .and_then(|content| content.first()) + .and_then(|entry| entry.get("text")) + .and_then(Value::as_str) + .is_some_and(|text| text.trim_start().starts_with("")) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] /// Scenario: compact an initial conversation, resume it, fork one turn back, and /// ensure the model-visible history matches expectations at each request. @@ -61,7 +83,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() { user_turn(&base, "hello world").await; compact_conversation(&base).await; user_turn(&base, "AFTER_COMPACT").await; - let base_path = fetch_conversation_path(&base, "base conversation").await; + let base_path = fetch_conversation_path(&base).await; assert!( base_path.exists(), "compact+resume test expects base path {base_path:?} to exist", @@ -69,7 +91,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() { let resumed = resume_conversation(&manager, &config, base_path).await; user_turn(&resumed, "AFTER_RESUME").await; - let resumed_path = fetch_conversation_path(&resumed, "resumed conversation").await; + let resumed_path = fetch_conversation_path(&resumed).await; assert!( resumed_path.exists(), "compact+resume test expects resumed path {resumed_path:?} to exist", @@ -518,7 +540,7 @@ async fn compact_resume_after_second_compaction_preserves_history() { user_turn(&base, "hello world").await; compact_conversation(&base).await; user_turn(&base, "AFTER_COMPACT").await; - let base_path = fetch_conversation_path(&base, "base conversation").await; + let base_path = fetch_conversation_path(&base).await; assert!( base_path.exists(), "second compact test expects base path {base_path:?} to exist", @@ -526,7 +548,7 @@ async fn compact_resume_after_second_compaction_preserves_history() { let resumed = resume_conversation(&manager, &config, base_path).await; user_turn(&resumed, "AFTER_RESUME").await; - let resumed_path = fetch_conversation_path(&resumed, "resumed conversation").await; + let resumed_path = fetch_conversation_path(&resumed).await; assert!( resumed_path.exists(), "second compact test expects resumed path {resumed_path:?} to exist", @@ -537,7 +559,7 @@ async fn compact_resume_after_second_compaction_preserves_history() { compact_conversation(&forked).await; user_turn(&forked, "AFTER_COMPACT_2").await; - let forked_path = fetch_conversation_path(&forked, "forked conversation").await; + let forked_path = fetch_conversation_path(&forked).await; assert!( forked_path.exists(), "second compact test expects forked path {forked_path:?} to exist", @@ -557,13 +579,15 @@ async fn compact_resume_after_second_compaction_preserves_history() { let resume_input_array = input_after_resume .as_array() .expect("input after resume should be an array"); + let compact_filtered = filter_out_ghost_snapshot_entries(compact_input_array); + let resume_filtered = filter_out_ghost_snapshot_entries(resume_input_array); assert!( - compact_input_array.len() <= resume_input_array.len(), + compact_filtered.len() <= resume_filtered.len(), "after-resume input should have at least as many items as after-compact" ); assert_eq!( - compact_input_array.as_slice(), - &resume_input_array[..compact_input_array.len()] + compact_filtered.as_slice(), + &resume_filtered[..compact_filtered.len()] ); // hard coded test let prompt = requests[0]["instructions"] @@ -792,22 +816,8 @@ async fn compact_conversation(conversation: &Arc) { wait_for_event(conversation, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; } -async fn fetch_conversation_path( - conversation: &Arc, - context: &str, -) -> std::path::PathBuf { - conversation - .submit(Op::GetPath) - .await - .expect("request conversation path"); - match wait_for_event(conversation, |ev| { - matches!(ev, EventMsg::ConversationPath(_)) - }) - .await - { - EventMsg::ConversationPath(ConversationPathResponseEvent { path, .. }) => path, - _ => panic!("expected ConversationPath event for {context}"), - } +async fn fetch_conversation_path(conversation: &Arc) -> std::path::PathBuf { + conversation.rollout_path() } async fn resume_conversation( diff --git a/codex-rs/core/tests/suite/fork_conversation.rs b/codex-rs/core/tests/suite/fork_conversation.rs index da2ff8c3f6..75b37ae7ef 100644 --- a/codex-rs/core/tests/suite/fork_conversation.rs +++ b/codex-rs/core/tests/suite/fork_conversation.rs @@ -4,7 +4,6 @@ use codex_core::ModelProviderInfo; use codex_core::NewConversation; use codex_core::built_in_model_providers; use codex_core::parse_turn_item; -use codex_core::protocol::ConversationPathResponseEvent; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; use codex_core::protocol::RolloutItem; @@ -79,13 +78,7 @@ async fn fork_conversation_twice_drops_to_first_message() { } // Request history from the base conversation to obtain rollout path. - codex.submit(Op::GetPath).await.unwrap(); - let base_history = - wait_for_event(&codex, |ev| matches!(ev, EventMsg::ConversationPath(_))).await; - let base_path = match &base_history { - EventMsg::ConversationPath(ConversationPathResponseEvent { path, .. }) => path.clone(), - _ => panic!("expected ConversationHistory event"), - }; + let base_path = codex.rollout_path(); // GetHistory flushes before returning the path; no wait needed. @@ -140,15 +133,7 @@ async fn fork_conversation_twice_drops_to_first_message() { .await .expect("fork 1"); - codex_fork1.submit(Op::GetPath).await.unwrap(); - let fork1_history = wait_for_event(&codex_fork1, |ev| { - matches!(ev, EventMsg::ConversationPath(_)) - }) - .await; - let fork1_path = match &fork1_history { - EventMsg::ConversationPath(ConversationPathResponseEvent { path, .. }) => path.clone(), - _ => panic!("expected ConversationHistory event after first fork"), - }; + let fork1_path = codex_fork1.rollout_path(); // GetHistory on fork1 flushed; the file is ready. let fork1_items = read_items(&fork1_path); @@ -166,15 +151,7 @@ async fn fork_conversation_twice_drops_to_first_message() { .await .expect("fork 2"); - codex_fork2.submit(Op::GetPath).await.unwrap(); - let fork2_history = wait_for_event(&codex_fork2, |ev| { - matches!(ev, EventMsg::ConversationPath(_)) - }) - .await; - let fork2_path = match &fork2_history { - EventMsg::ConversationPath(ConversationPathResponseEvent { path, .. }) => path.clone(), - _ => panic!("expected ConversationHistory event after second fork"), - }; + let fork2_path = codex_fork2.rollout_path(); // GetHistory on fork2 flushed; the file is ready. let fork1_items = read_items(&fork1_path); let fork1_user_inputs = find_user_input_positions(&fork1_items); diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 9b79ac8385..bfaef15a92 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -3,6 +3,8 @@ #[cfg(not(target_os = "windows"))] mod abort_tasks; #[cfg(not(target_os = "windows"))] +mod apply_patch_cli; +#[cfg(not(target_os = "windows"))] mod approvals; mod cli_stream; mod client; diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 422acd922f..fdf7669d1d 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -7,7 +7,6 @@ use codex_core::REVIEW_PROMPT; use codex_core::ResponseItem; use codex_core::built_in_model_providers; use codex_core::config::Config; -use codex_core::protocol::ConversationPathResponseEvent; use codex_core::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; use codex_core::protocol::EventMsg; use codex_core::protocol::ExitedReviewModeEvent; @@ -120,13 +119,7 @@ async fn review_op_emits_lifecycle_and_review_output() { // Also verify that a user message with the header and a formatted finding // was recorded back in the parent session's rollout. - codex.submit(Op::GetPath).await.unwrap(); - let history_event = - wait_for_event(&codex, |ev| matches!(ev, EventMsg::ConversationPath(_))).await; - let path = match history_event { - EventMsg::ConversationPath(ConversationPathResponseEvent { path, .. }) => path, - other => panic!("expected ConversationPath event, got {other:?}"), - }; + let path = codex.rollout_path(); let text = std::fs::read_to_string(&path).expect("read rollout file"); let mut saw_header = false; @@ -375,7 +368,8 @@ async fn review_input_isolated_from_parent_history() { "instructions": null, "cwd": ".", "originator": "test_originator", - "cli_version": "test_version" + "cli_version": "test_version", + "model_provider": "test-provider" } }); f.write_all(format!("{meta_line}\n").as_bytes()) @@ -482,13 +476,7 @@ async fn review_input_isolated_from_parent_history() { assert_eq!(instructions, REVIEW_PROMPT); // Also verify that a user interruption note was recorded in the rollout. - codex.submit(Op::GetPath).await.unwrap(); - let history_event = - wait_for_event(&codex, |ev| matches!(ev, EventMsg::ConversationPath(_))).await; - let path = match history_event { - EventMsg::ConversationPath(ConversationPathResponseEvent { path, .. }) => path, - other => panic!("expected ConversationPath event, got {other:?}"), - }; + let path = codex.rollout_path(); let text = std::fs::read_to_string(&path).expect("read rollout file"); let mut saw_interruption_message = false; for line in text.lines() { diff --git a/codex-rs/core/tests/suite/rollout_list_find.rs b/codex-rs/core/tests/suite/rollout_list_find.rs index 88409a4616..1d40718d4b 100644 --- a/codex-rs/core/tests/suite/rollout_list_find.rs +++ b/codex-rs/core/tests/suite/rollout_list_find.rs @@ -1,5 +1,6 @@ #![allow(clippy::unwrap_used, clippy::expect_used)] use std::io::Write; +use std::path::Path; use std::path::PathBuf; use codex_core::find_conversation_path_by_id_str; @@ -8,8 +9,8 @@ use uuid::Uuid; /// Create sessions/YYYY/MM/DD and write a minimal rollout file containing the /// provided conversation id in the SessionMeta line. Returns the absolute path. -fn write_minimal_rollout_with_id(codex_home: &TempDir, id: Uuid) -> PathBuf { - let sessions = codex_home.path().join("sessions/2024/01/01"); +fn write_minimal_rollout_with_id(codex_home: &Path, id: Uuid) -> PathBuf { + let sessions = codex_home.join("sessions/2024/01/01"); std::fs::create_dir_all(&sessions).unwrap(); let file = sessions.join(format!("rollout-2024-01-01T00-00-00-{id}.jsonl")); @@ -27,7 +28,8 @@ fn write_minimal_rollout_with_id(codex_home: &TempDir, id: Uuid) -> PathBuf { "instructions": null, "cwd": ".", "originator": "test", - "cli_version": "test" + "cli_version": "test", + "model_provider": "test-provider" } }) ) @@ -40,7 +42,7 @@ fn write_minimal_rollout_with_id(codex_home: &TempDir, id: Uuid) -> PathBuf { async fn find_locates_rollout_file_by_id() { let home = TempDir::new().unwrap(); let id = Uuid::new_v4(); - let expected = write_minimal_rollout_with_id(&home, id); + let expected = write_minimal_rollout_with_id(home.path(), id); let found = find_conversation_path_by_id_str(home.path(), &id.to_string()) .await @@ -48,3 +50,33 @@ async fn find_locates_rollout_file_by_id() { assert_eq!(found.unwrap(), expected); } + +#[tokio::test] +async fn find_handles_gitignore_covering_codex_home_directory() { + let repo = TempDir::new().unwrap(); + let codex_home = repo.path().join(".codex"); + std::fs::create_dir_all(&codex_home).unwrap(); + std::fs::write(repo.path().join(".gitignore"), ".codex/**\n").unwrap(); + let id = Uuid::new_v4(); + let expected = write_minimal_rollout_with_id(&codex_home, id); + + let found = find_conversation_path_by_id_str(&codex_home, &id.to_string()) + .await + .unwrap(); + + assert_eq!(found, Some(expected)); +} + +#[tokio::test] +async fn find_ignores_granular_gitignore_rules() { + let home = TempDir::new().unwrap(); + let id = Uuid::new_v4(); + let expected = write_minimal_rollout_with_id(home.path(), id); + std::fs::write(home.path().join("sessions/.gitignore"), "*.jsonl\n").unwrap(); + + let found = find_conversation_path_by_id_str(home.path(), &id.to_string()) + .await + .unwrap(); + + assert_eq!(found, Some(expected)); +} diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 710eb0bc82..0e68ec7585 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -133,6 +133,262 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unified_exec_emits_exec_command_end_event() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + + let mut builder = test_codex().with_config(|config| { + config.use_experimental_unified_exec_tool = true; + config.features.enable(Feature::UnifiedExec); + }); + let TestCodex { + codex, + cwd, + session_configured, + .. + } = builder.build(&server).await?; + + let call_id = "uexec-end-event"; + let args = json!({ + "cmd": "/bin/echo END-EVENT".to_string(), + "yield_time_ms": 250, + }); + let poll_call_id = "uexec-end-event-poll"; + let poll_args = json!({ + "chars": "", + "session_id": 0, + "yield_time_ms": 250, + }); + + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_function_call( + poll_call_id, + "write_stdin", + &serde_json::to_string(&poll_args)?, + ), + ev_completed("resp-2"), + ]), + sse(vec![ + ev_response_created("resp-3"), + ev_assistant_message("msg-1", "finished"), + ev_completed("resp-3"), + ]), + ]; + mount_sse_sequence(&server, responses).await; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "emit end event".into(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + let end_event = wait_for_event_match(&codex, |msg| match msg { + EventMsg::ExecCommandEnd(ev) if ev.call_id == call_id => Some(ev.clone()), + _ => None, + }) + .await; + + assert_eq!(end_event.exit_code, 0); + assert!( + end_event.aggregated_output.contains("END-EVENT"), + "expected aggregated output to contain marker" + ); + + wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + + let mut builder = test_codex().with_config(|config| { + config.use_experimental_unified_exec_tool = true; + config.features.enable(Feature::UnifiedExec); + }); + let TestCodex { + codex, + cwd, + session_configured, + .. + } = builder.build(&server).await?; + + let call_id = "uexec-delta-1"; + let args = json!({ + "cmd": "printf 'HELLO-UEXEC'", + "yield_time_ms": 1000, + }); + + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "finished"), + ev_completed("resp-2"), + ]), + ]; + mount_sse_sequence(&server, responses).await; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "emit delta".into(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + let delta = wait_for_event_match(&codex, |msg| match msg { + EventMsg::ExecCommandOutputDelta(ev) if ev.call_id == call_id => Some(ev.clone()), + _ => None, + }) + .await; + + let text = String::from_utf8_lossy(&delta.chunk).to_string(); + assert!( + text.contains("HELLO-UEXEC"), + "delta chunk missing expected text: {text:?}" + ); + + wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unified_exec_emits_output_delta_for_write_stdin() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + + let mut builder = test_codex().with_config(|config| { + config.use_experimental_unified_exec_tool = true; + config.features.enable(Feature::UnifiedExec); + }); + let TestCodex { + codex, + cwd, + session_configured, + .. + } = builder.build(&server).await?; + + let open_call_id = "uexec-open"; + let open_args = json!({ + "cmd": "/bin/bash -i", + "yield_time_ms": 200, + }); + + let stdin_call_id = "uexec-stdin-delta"; + let stdin_args = json!({ + "chars": "echo WSTDIN-MARK\\n", + "session_id": 0, + "yield_time_ms": 800, + }); + + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + open_call_id, + "exec_command", + &serde_json::to_string(&open_args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_function_call( + stdin_call_id, + "write_stdin", + &serde_json::to_string(&stdin_args)?, + ), + ev_completed("resp-2"), + ]), + sse(vec![ + ev_response_created("resp-3"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-3"), + ]), + ]; + mount_sse_sequence(&server, responses).await; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "stdin delta".into(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + // Expect a delta event corresponding to the write_stdin call. + let delta = wait_for_event_match(&codex, |msg| match msg { + EventMsg::ExecCommandOutputDelta(ev) if ev.call_id == open_call_id => { + let text = String::from_utf8_lossy(&ev.chunk); + if text.contains("WSTDIN-MARK") { + Some(ev.clone()) + } else { + None + } + } + _ => None, + }) + .await; + + let text = String::from_utf8_lossy(&delta.chunk).to_string(); + assert!( + text.contains("WSTDIN-MARK"), + "stdin delta chunk missing expected text: {text:?}" + ); + + wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn unified_exec_skips_begin_event_for_empty_input() -> Result<()> { use tokio::time::Duration; @@ -516,6 +772,110 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + + let mut builder = test_codex().with_config(|config| { + config.use_experimental_unified_exec_tool = true; + config.features.enable(Feature::UnifiedExec); + }); + let TestCodex { + codex, + cwd, + session_configured, + .. + } = builder.build(&server).await?; + + let start_call_id = "uexec-end-on-exit-start"; + let start_args = serde_json::json!({ + "cmd": "/bin/cat", + "yield_time_ms": 200, + }); + + let echo_call_id = "uexec-end-on-exit-echo"; + let echo_args = serde_json::json!({ + "chars": "bye-END\n", + "session_id": 0, + "yield_time_ms": 300, + }); + + let exit_call_id = "uexec-end-on-exit"; + let exit_args = serde_json::json!({ + "chars": "\u{0004}", + "session_id": 0, + "yield_time_ms": 500, + }); + + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + start_call_id, + "exec_command", + &serde_json::to_string(&start_args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_function_call( + echo_call_id, + "write_stdin", + &serde_json::to_string(&echo_args)?, + ), + ev_completed("resp-2"), + ]), + sse(vec![ + ev_response_created("resp-3"), + ev_function_call( + exit_call_id, + "write_stdin", + &serde_json::to_string(&exit_args)?, + ), + ev_completed("resp-3"), + ]), + sse(vec![ + ev_response_created("resp-4"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-4"), + ]), + ]; + mount_sse_sequence(&server, responses).await; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "end on exit".into(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + // We expect the ExecCommandEnd event to match the initial exec_command call_id. + let end_event = wait_for_event_match(&codex, |msg| match msg { + EventMsg::ExecCommandEnd(ev) if ev.call_id == start_call_id => Some(ev.clone()), + _ => None, + }) + .await; + + assert_eq!(end_event.exit_code, 0); + + wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn unified_exec_reuses_session_via_stdin() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 9a85c5de71..4db6a50461 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -20,7 +20,6 @@ use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TurnAbortReason; use codex_core::protocol::TurnDiffEvent; -use codex_core::protocol::WebSearchBeginEvent; use codex_core::protocol::WebSearchEndEvent; use codex_protocol::num_format::format_with_separators; use owo_colors::OwoColorize; @@ -216,7 +215,6 @@ impl EventProcessor for EventProcessorWithHumanOutput { cwd.to_string_lossy(), ); } - EventMsg::ExecCommandOutputDelta(_) => {} EventMsg::ExecCommandEnd(ExecCommandEndEvent { aggregated_output, duration, @@ -283,7 +281,6 @@ impl EventProcessor for EventProcessorWithHumanOutput { } } } - EventMsg::WebSearchBegin(WebSearchBeginEvent { call_id: _ }) => {} EventMsg::WebSearchEnd(WebSearchEndEvent { call_id: _, query }) => { ts_msg!(self, "🌐 Searched: {query}"); } @@ -411,12 +408,6 @@ impl EventProcessor for EventProcessorWithHumanOutput { ); eprintln!("{unified_diff}"); } - EventMsg::ExecApprovalRequest(_) => { - // Should we exit? - } - EventMsg::ApplyPatchApprovalRequest(_) => { - // Should we exit? - } EventMsg::AgentReasoning(agent_reasoning_event) => { if self.show_agent_reasoning { ts_msg!( @@ -481,15 +472,6 @@ impl EventProcessor for EventProcessorWithHumanOutput { } } } - EventMsg::GetHistoryEntryResponse(_) => { - // Currently ignored in exec output. - } - EventMsg::McpListToolsResponse(_) => { - // Currently ignored in exec output. - } - EventMsg::ListCustomPromptsResponse(_) => { - // Currently ignored in exec output. - } EventMsg::ViewImageToolCall(view) => { ts_msg!( self, @@ -510,15 +492,24 @@ impl EventProcessor for EventProcessorWithHumanOutput { } }, EventMsg::ShutdownComplete => return CodexStatus::Shutdown, - EventMsg::ConversationPath(_) => {} - EventMsg::UserMessage(_) => {} - EventMsg::EnteredReviewMode(_) => {} - EventMsg::ExitedReviewMode(_) => {} - EventMsg::AgentMessageDelta(_) => {} - EventMsg::AgentReasoningDelta(_) => {} - EventMsg::AgentReasoningRawContentDelta(_) => {} - EventMsg::ItemStarted(_) => {} - EventMsg::ItemCompleted(_) => {} + EventMsg::WebSearchBegin(_) + | EventMsg::ExecApprovalRequest(_) + | EventMsg::ApplyPatchApprovalRequest(_) + | EventMsg::ExecCommandOutputDelta(_) + | EventMsg::GetHistoryEntryResponse(_) + | EventMsg::McpListToolsResponse(_) + | EventMsg::ListCustomPromptsResponse(_) + | EventMsg::RawResponseItem(_) + | EventMsg::UserMessage(_) + | EventMsg::EnteredReviewMode(_) + | EventMsg::ExitedReviewMode(_) + | EventMsg::AgentMessageDelta(_) + | EventMsg::AgentReasoningDelta(_) + | EventMsg::AgentReasoningRawContentDelta(_) + | EventMsg::ItemStarted(_) + | EventMsg::ItemCompleted(_) + | EventMsg::UndoCompleted(_) + | EventMsg::UndoStarted(_) => {} } CodexStatus::Running } diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index e470194cad..10a4376161 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -179,6 +179,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any include_view_image_tool: None, show_raw_agent_reasoning: oss.then_some(true), tools_web_search_request: None, + experimental_sandbox_command_assessment: None, additional_writable_roots: Vec::new(), }; // Parse `-c` overrides. @@ -388,8 +389,16 @@ async fn resolve_resume_path( args: &crate::cli::ResumeArgs, ) -> anyhow::Result> { if args.last { - match codex_core::RolloutRecorder::list_conversations(&config.codex_home, 1, None, &[]) - .await + let default_provider_filter = vec![config.model_provider_id.clone()]; + match codex_core::RolloutRecorder::list_conversations( + &config.codex_home, + 1, + None, + &[], + Some(default_provider_filter.as_slice()), + &config.model_provider_id, + ) + .await { Ok(page) => Ok(page.items.first().map(|it| it.path.clone())), Err(e) => { diff --git a/codex-rs/feedback/src/lib.rs b/codex-rs/feedback/src/lib.rs index 285ab0661d..e1ccc3aac9 100644 --- a/codex-rs/feedback/src/lib.rs +++ b/codex-rs/feedback/src/lib.rs @@ -12,7 +12,7 @@ use anyhow::anyhow; use codex_protocol::ConversationId; use tracing_subscriber::fmt::writer::MakeWriter; -const DEFAULT_MAX_BYTES: usize = 2 * 1024 * 1024; // 2 MiB +const DEFAULT_MAX_BYTES: usize = 4 * 1024 * 1024; // 4 MiB const SENTRY_DSN: &str = "https://ae32ed50620d7a7792c1ce5df38b3e3e@o33249.ingest.us.sentry.io/4510195390611458"; const UPLOAD_TIMEOUT_SECS: u64 = 10; @@ -167,8 +167,16 @@ impl CodexLogSnapshot { Ok(path) } - pub fn upload_to_sentry(&self) -> Result<()> { + /// Upload feedback to Sentry with optional attachments. + pub fn upload_feedback( + &self, + classification: &str, + reason: Option<&str>, + include_logs: bool, + rollout_path: Option<&std::path::Path>, + ) -> Result<()> { use std::collections::BTreeMap; + use std::fs; use std::str::FromStr; use std::sync::Arc; @@ -182,36 +190,91 @@ impl CodexLogSnapshot { use sentry::transports::DefaultTransportFactory; use sentry::types::Dsn; + // Build Sentry client let client = Client::from_config(ClientOptions { - dsn: Some(Dsn::from_str(SENTRY_DSN).map_err(|e| anyhow!("invalid DSN: {}", e))?), + dsn: Some(Dsn::from_str(SENTRY_DSN).map_err(|e| anyhow!("invalid DSN: {e}"))?), transport: Some(Arc::new(DefaultTransportFactory {})), ..Default::default() }); - let tags = BTreeMap::from([(String::from("thread_id"), self.thread_id.to_string())]); + let cli_version = env!("CARGO_PKG_VERSION"); + let mut tags = BTreeMap::from([ + (String::from("thread_id"), self.thread_id.to_string()), + (String::from("classification"), classification.to_string()), + (String::from("cli_version"), cli_version.to_string()), + ]); + if let Some(r) = reason { + tags.insert(String::from("reason"), r.to_string()); + } - let event = Event { - level: Level::Error, - message: Some("Codex Log Upload ".to_string() + &self.thread_id), + let level = match classification { + "bug" | "bad_result" => Level::Error, + _ => Level::Info, + }; + + let mut envelope = Envelope::new(); + let title = format!( + "[{}]: Codex session {}", + display_classification(classification), + self.thread_id + ); + + let mut event = Event { + level, + message: Some(title.clone()), tags, ..Default::default() }; - let mut envelope = Envelope::new(); + if let Some(r) = reason { + use sentry::protocol::Exception; + use sentry::protocol::Values; + + event.exception = Values::from(vec![Exception { + ty: title.clone(), + value: Some(r.to_string()), + ..Default::default() + }]); + } envelope.add_item(EnvelopeItem::Event(event)); - envelope.add_item(EnvelopeItem::Attachment(Attachment { - buffer: self.bytes.clone(), - filename: String::from("codex-logs.log"), - content_type: Some("text/plain".to_string()), - ty: None, - })); + + if include_logs { + envelope.add_item(EnvelopeItem::Attachment(Attachment { + buffer: self.bytes.clone(), + filename: String::from("codex-logs.log"), + content_type: Some("text/plain".to_string()), + ty: None, + })); + } + + if let Some((path, data)) = rollout_path.and_then(|p| fs::read(p).ok().map(|d| (p, d))) { + let fname = path + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "rollout.jsonl".to_string()); + let content_type = "text/plain".to_string(); + envelope.add_item(EnvelopeItem::Attachment(Attachment { + buffer: data, + filename: fname, + content_type: Some(content_type), + ty: None, + })); + } client.send_envelope(envelope); client.flush(Some(Duration::from_secs(UPLOAD_TIMEOUT_SECS))); - Ok(()) } } +fn display_classification(classification: &str) -> String { + match classification { + "bug" => "Bug".to_string(), + "bad_result" => "Bad result".to_string(), + "good_result" => "Good result".to_string(), + _ => "Other".to_string(), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/file-search/src/lib.rs b/codex-rs/file-search/src/lib.rs index 82b8fcb695..17265a85f6 100644 --- a/codex-rs/file-search/src/lib.rs +++ b/codex-rs/file-search/src/lib.rs @@ -105,6 +105,7 @@ pub async fn run_main( threads, cancel_flag, compute_indices, + true, )?; let match_count = matches.len(); let matches_truncated = total_match_count > match_count; @@ -121,6 +122,7 @@ pub async fn run_main( /// The worker threads will periodically check `cancel_flag` to see if they /// should stop processing files. +#[allow(clippy::too_many_arguments)] pub fn run( pattern_text: &str, limit: NonZero, @@ -129,6 +131,7 @@ pub fn run( threads: NonZero, cancel_flag: Arc, compute_indices: bool, + respect_gitignore: bool, ) -> anyhow::Result { let pattern = create_pattern(pattern_text); // Create one BestMatchesList per worker thread so that each worker can @@ -157,6 +160,14 @@ pub fn run( .hidden(false) // Don't require git to be present to apply to apply git-related ignore rules. .require_git(false); + if !respect_gitignore { + walk_builder + .git_ignore(false) + .git_global(false) + .git_exclude(false) + .ignore(false) + .parents(false); + } if !exclude.is_empty() { let mut override_builder = OverrideBuilder::new(search_directory); diff --git a/codex-rs/git-tooling/Cargo.toml b/codex-rs/git-tooling/Cargo.toml index 183221f3e1..c30d58de8d 100644 --- a/codex-rs/git-tooling/Cargo.toml +++ b/codex-rs/git-tooling/Cargo.toml @@ -9,13 +9,20 @@ name = "codex_git_tooling" path = "src/lib.rs" [dependencies] -tempfile = "3" -thiserror = "2" -walkdir = "2" +tempfile = { workspace = true } +thiserror = { workspace = true } +walkdir = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true, features = ["derive"] } +ts-rs = { workspace = true, features = [ + "uuid-impl", + "serde-json-impl", + "no-serde-warnings", +] } [lints] workspace = true [dev-dependencies] assert_matches = { workspace = true } -pretty_assertions = "1.4.1" +pretty_assertions = { workspace = true } diff --git a/codex-rs/git-tooling/src/ghost_commits.rs b/codex-rs/git-tooling/src/ghost_commits.rs index 6c2a86166c..ec211ac3e1 100644 --- a/codex-rs/git-tooling/src/ghost_commits.rs +++ b/codex-rs/git-tooling/src/ghost_commits.rs @@ -1,4 +1,7 @@ +use std::collections::HashSet; use std::ffi::OsString; +use std::fs; +use std::io; use std::path::Path; use std::path::PathBuf; @@ -14,6 +17,7 @@ use crate::operations::resolve_head; use crate::operations::resolve_repository_root; use crate::operations::run_git_for_status; use crate::operations::run_git_for_stdout; +use crate::operations::run_git_for_stdout_all; /// Default commit message used for ghost commits when none is provided. const DEFAULT_COMMIT_MESSAGE: &str = "codex snapshot"; @@ -69,6 +73,8 @@ pub fn create_ghost_commit( let repo_root = resolve_repository_root(options.repo_path)?; let repo_prefix = repo_subdir(repo_root.as_path(), options.repo_path); let parent = resolve_head(repo_root.as_path())?; + let existing_untracked = + capture_existing_untracked(repo_root.as_path(), repo_prefix.as_deref())?; let normalized_force = options .force_include @@ -84,6 +90,16 @@ pub fn create_ghost_commit( OsString::from(index_path.as_os_str()), )]; + // Pre-populate the temporary index with HEAD so unchanged tracked files + // are included in the snapshot tree. + if let Some(parent_sha) = parent.as_deref() { + run_git_for_status( + repo_root.as_path(), + vec![OsString::from("read-tree"), OsString::from(parent_sha)], + Some(base_env.as_slice()), + )?; + } + let mut add_args = vec![OsString::from("add"), OsString::from("--all")]; if let Some(prefix) = repo_prefix.as_deref() { add_args.extend([OsString::from("--"), prefix.as_os_str().to_os_string()]); @@ -127,12 +143,29 @@ pub fn create_ghost_commit( Some(commit_env.as_slice()), )?; - Ok(GhostCommit::new(commit_id, parent)) + Ok(GhostCommit::new( + commit_id, + parent, + existing_untracked.files, + existing_untracked.dirs, + )) } /// Restore the working tree to match the provided ghost commit. pub fn restore_ghost_commit(repo_path: &Path, commit: &GhostCommit) -> Result<(), GitToolingError> { - restore_to_commit(repo_path, commit.id()) + ensure_git_repository(repo_path)?; + + let repo_root = resolve_repository_root(repo_path)?; + let repo_prefix = repo_subdir(repo_root.as_path(), repo_path); + let current_untracked = + capture_existing_untracked(repo_root.as_path(), repo_prefix.as_deref())?; + restore_to_commit_inner(repo_root.as_path(), repo_prefix.as_deref(), commit.id())?; + remove_new_untracked( + repo_root.as_path(), + commit.preexisting_untracked_files(), + commit.preexisting_untracked_dirs(), + current_untracked, + ) } /// Restore the working tree to match the given commit ID. @@ -141,7 +174,16 @@ pub fn restore_to_commit(repo_path: &Path, commit_id: &str) -> Result<(), GitToo let repo_root = resolve_repository_root(repo_path)?; let repo_prefix = repo_subdir(repo_root.as_path(), repo_path); + restore_to_commit_inner(repo_root.as_path(), repo_prefix.as_deref(), commit_id) +} +/// Restores the working tree and index to the given commit using `git restore`. +/// The repository root and optional repository-relative prefix limit the restore scope. +fn restore_to_commit_inner( + repo_root: &Path, + repo_prefix: Option<&Path>, + commit_id: &str, +) -> Result<(), GitToolingError> { let mut restore_args = vec![ OsString::from("restore"), OsString::from("--source"), @@ -150,13 +192,143 @@ pub fn restore_to_commit(repo_path: &Path, commit_id: &str) -> Result<(), GitToo OsString::from("--staged"), OsString::from("--"), ]; - if let Some(prefix) = repo_prefix.as_deref() { + if let Some(prefix) = repo_prefix { restore_args.push(prefix.as_os_str().to_os_string()); } else { restore_args.push(OsString::from(".")); } - run_git_for_status(repo_root.as_path(), restore_args, None)?; + run_git_for_status(repo_root, restore_args, None)?; + Ok(()) +} + +#[derive(Default)] +struct UntrackedSnapshot { + files: Vec, + dirs: Vec, +} + +/// Captures the untracked and ignored entries under `repo_root`, optionally limited by `repo_prefix`. +/// Returns the result as an `UntrackedSnapshot`. +fn capture_existing_untracked( + repo_root: &Path, + repo_prefix: Option<&Path>, +) -> Result { + // Ask git for the zero-delimited porcelain status so we can enumerate + // every untracked or ignored path (including ones filtered by prefix). + let mut args = vec![ + OsString::from("status"), + OsString::from("--porcelain=2"), + OsString::from("-z"), + OsString::from("--ignored=matching"), + OsString::from("--untracked-files=all"), + ]; + if let Some(prefix) = repo_prefix { + args.push(OsString::from("--")); + args.push(prefix.as_os_str().to_os_string()); + } + + let output = run_git_for_stdout_all(repo_root, args, None)?; + if output.is_empty() { + return Ok(UntrackedSnapshot::default()); + } + + let mut snapshot = UntrackedSnapshot::default(); + // Each entry is of the form " " where code is '?' (untracked) + // or '!' (ignored); everything else is irrelevant to this snapshot. + for entry in output.split('\0') { + if entry.is_empty() { + continue; + } + let mut parts = entry.splitn(2, ' '); + let code = parts.next(); + let path_part = parts.next(); + let (Some(code), Some(path_part)) = (code, path_part) else { + continue; + }; + if code != "?" && code != "!" { + continue; + } + if path_part.is_empty() { + continue; + } + + let normalized = normalize_relative_path(Path::new(path_part))?; + let absolute = repo_root.join(&normalized); + let is_dir = absolute.is_dir(); + if is_dir { + snapshot.dirs.push(normalized); + } else { + snapshot.files.push(normalized); + } + } + + Ok(snapshot) +} + +/// Removes untracked files and directories that were not present when the snapshot was captured. +fn remove_new_untracked( + repo_root: &Path, + preserved_files: &[PathBuf], + preserved_dirs: &[PathBuf], + current: UntrackedSnapshot, +) -> Result<(), GitToolingError> { + if current.files.is_empty() && current.dirs.is_empty() { + return Ok(()); + } + + let preserved_file_set: HashSet = preserved_files.iter().cloned().collect(); + let preserved_dirs_vec: Vec = preserved_dirs.to_vec(); + + for path in current.files { + if should_preserve(&path, &preserved_file_set, &preserved_dirs_vec) { + continue; + } + remove_path(&repo_root.join(&path))?; + } + + for dir in current.dirs { + if should_preserve(&dir, &preserved_file_set, &preserved_dirs_vec) { + continue; + } + remove_path(&repo_root.join(&dir))?; + } + + Ok(()) +} + +/// Determines whether an untracked path should be kept because it existed in the snapshot. +fn should_preserve( + path: &Path, + preserved_files: &HashSet, + preserved_dirs: &[PathBuf], +) -> bool { + if preserved_files.contains(path) { + return true; + } + + preserved_dirs + .iter() + .any(|dir| path.starts_with(dir.as_path())) +} + +/// Deletes the file or directory at the provided path, ignoring if it is already absent. +fn remove_path(path: &Path) -> Result<(), GitToolingError> { + match fs::symlink_metadata(path) { + Ok(metadata) => { + if metadata.is_dir() { + fs::remove_dir_all(path)?; + } else { + fs::remove_file(path)?; + } + } + Err(err) => { + if err.kind() == io::ErrorKind::NotFound { + return Ok(()); + } + return Err(err.into()); + } + } Ok(()) } @@ -239,6 +411,9 @@ mod tests { ], ); + let preexisting_untracked = repo.join("notes.txt"); + std::fs::write(&preexisting_untracked, "notes before\n")?; + let tracked_contents = "modified contents\n"; std::fs::write(repo.join("tracked.txt"), tracked_contents)?; std::fs::remove_file(repo.join("delete-me.txt"))?; @@ -267,6 +442,7 @@ mod tests { std::fs::write(repo.join("ignored.txt"), "changed\n")?; std::fs::remove_file(repo.join("new-file.txt"))?; std::fs::write(repo.join("ephemeral.txt"), "temp data\n")?; + std::fs::write(&preexisting_untracked, "notes after\n")?; restore_ghost_commit(repo, &ghost)?; @@ -277,7 +453,9 @@ mod tests { let new_file_after = std::fs::read_to_string(repo.join("new-file.txt"))?; assert_eq!(new_file_after, new_file_contents); assert_eq!(repo.join("delete-me.txt").exists(), false); - assert!(repo.join("ephemeral.txt").exists()); + assert!(!repo.join("ephemeral.txt").exists()); + let notes_after = std::fs::read_to_string(&preexisting_untracked)?; + assert_eq!(notes_after, "notes before\n"); Ok(()) } @@ -488,7 +666,43 @@ mod tests { assert!(vscode.join("settings.json").exists()); let settings_after = std::fs::read_to_string(vscode.join("settings.json"))?; assert_eq!(settings_after, "{\n \"after\": true\n}\n"); - assert!(repo.join("temp.txt").exists()); + assert!(!repo.join("temp.txt").exists()); + + Ok(()) + } + + #[test] + /// Restoring removes ignored directories created after the snapshot. + fn restore_removes_new_ignored_directory() -> Result<(), GitToolingError> { + let temp = tempfile::tempdir()?; + let repo = temp.path(); + init_test_repo(repo); + + std::fs::write(repo.join(".gitignore"), ".vscode/\n")?; + std::fs::write(repo.join("tracked.txt"), "snapshot version\n")?; + run_git_in(repo, &["add", ".gitignore", "tracked.txt"]); + run_git_in( + repo, + &[ + "-c", + "user.name=Tester", + "-c", + "user.email=test@example.com", + "commit", + "-m", + "initial", + ], + ); + + let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(repo))?; + + let vscode = repo.join(".vscode"); + std::fs::create_dir_all(&vscode)?; + std::fs::write(vscode.join("settings.json"), "{\n \"after\": true\n}\n")?; + + restore_ghost_commit(repo, &ghost)?; + + assert!(!vscode.exists()); Ok(()) } diff --git a/codex-rs/git-tooling/src/lib.rs b/codex-rs/git-tooling/src/lib.rs index f41d104385..e6e53924b6 100644 --- a/codex-rs/git-tooling/src/lib.rs +++ b/codex-rs/git-tooling/src/lib.rs @@ -1,4 +1,5 @@ use std::fmt; +use std::path::PathBuf; mod errors; mod ghost_commits; @@ -11,18 +12,36 @@ pub use ghost_commits::create_ghost_commit; pub use ghost_commits::restore_ghost_commit; pub use ghost_commits::restore_to_commit; pub use platform::create_symlink; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +type CommitID = String; /// Details of a ghost commit created from a repository state. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] pub struct GhostCommit { - id: String, - parent: Option, + id: CommitID, + parent: Option, + preexisting_untracked_files: Vec, + preexisting_untracked_dirs: Vec, } impl GhostCommit { /// Create a new ghost commit wrapper from a raw commit ID and optional parent. - pub fn new(id: String, parent: Option) -> Self { - Self { id, parent } + pub fn new( + id: CommitID, + parent: Option, + preexisting_untracked_files: Vec, + preexisting_untracked_dirs: Vec, + ) -> Self { + Self { + id, + parent, + preexisting_untracked_files, + preexisting_untracked_dirs, + } } /// Commit ID for the snapshot. @@ -34,6 +53,16 @@ impl GhostCommit { pub fn parent(&self) -> Option<&str> { self.parent.as_deref() } + + /// Untracked or ignored files that already existed when the snapshot was captured. + pub fn preexisting_untracked_files(&self) -> &[PathBuf] { + &self.preexisting_untracked_files + } + + /// Untracked or ignored directories that already existed when the snapshot was captured. + pub fn preexisting_untracked_dirs(&self) -> &[PathBuf] { + &self.preexisting_untracked_dirs + } } impl fmt::Display for GhostCommit { diff --git a/codex-rs/git-tooling/src/operations.rs b/codex-rs/git-tooling/src/operations.rs index 3b387f8498..415d63d568 100644 --- a/codex-rs/git-tooling/src/operations.rs +++ b/codex-rs/git-tooling/src/operations.rs @@ -161,6 +161,27 @@ where }) } +/// Executes `git` and returns the full stdout without trimming so callers +/// can parse delimiter-sensitive output, propagating UTF-8 errors with context. +pub(crate) fn run_git_for_stdout_all( + dir: &Path, + args: I, + env: Option<&[(OsString, OsString)]>, +) -> Result +where + I: IntoIterator, + S: AsRef, +{ + // Keep the raw stdout untouched so callers can parse delimiter-sensitive + // output (e.g. NUL-separated paths) without trimming artefacts. + let run = run_git(dir, args, env)?; + // Propagate UTF-8 conversion failures with the command context for debugging. + String::from_utf8(run.output.stdout).map_err(|source| GitToolingError::GitOutputUtf8 { + command: run.command, + source, + }) +} + fn run_git( dir: &Path, args: I, diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index b4c4eb749d..24a5eec4b8 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -158,6 +158,7 @@ impl CodexToolCallParam { include_view_image_tool: None, show_raw_agent_reasoning: None, tools_web_search_request: None, + experimental_sandbox_command_assessment: None, additional_writable_roots: Vec::new(), }; diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index a59755008d..9ba48c018c 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -178,6 +178,7 @@ async fn run_codex_tool_session_inner( cwd, call_id, reason: _, + risk, parsed_cmd, }) => { handle_exec_approval_request( @@ -190,6 +191,7 @@ async fn run_codex_tool_session_inner( event.id.clone(), call_id, parsed_cmd, + risk, ) .await; continue; @@ -279,13 +281,15 @@ async fn run_codex_tool_session_inner( | EventMsg::GetHistoryEntryResponse(_) | EventMsg::PlanUpdate(_) | EventMsg::TurnAborted(_) - | EventMsg::ConversationPath(_) | EventMsg::UserMessage(_) | EventMsg::ShutdownComplete | EventMsg::ViewImageToolCall(_) + | EventMsg::RawResponseItem(_) | EventMsg::EnteredReviewMode(_) | EventMsg::ItemStarted(_) | EventMsg::ItemCompleted(_) + | EventMsg::UndoStarted(_) + | EventMsg::UndoCompleted(_) | EventMsg::ExitedReviewMode(_) => { // For now, we do not do anything extra for these // events. Note that diff --git a/codex-rs/mcp-server/src/exec_approval.rs b/codex-rs/mcp-server/src/exec_approval.rs index 44607b754d..033523ac0d 100644 --- a/codex-rs/mcp-server/src/exec_approval.rs +++ b/codex-rs/mcp-server/src/exec_approval.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use codex_core::CodexConversation; use codex_core::protocol::Op; use codex_core::protocol::ReviewDecision; +use codex_core::protocol::SandboxCommandAssessment; use codex_protocol::parse_command::ParsedCommand; use mcp_types::ElicitRequest; use mcp_types::ElicitRequestParamsRequestedSchema; @@ -37,6 +38,8 @@ pub struct ExecApprovalElicitRequestParams { pub codex_command: Vec, pub codex_cwd: PathBuf, pub codex_parsed_cmd: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub codex_risk: Option, } // TODO(mbolin): ExecApprovalResponse does not conform to ElicitResult. See: @@ -59,6 +62,7 @@ pub(crate) async fn handle_exec_approval_request( event_id: String, call_id: String, codex_parsed_cmd: Vec, + codex_risk: Option, ) { let escaped_command = shlex::try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" ")); @@ -81,6 +85,7 @@ pub(crate) async fn handle_exec_approval_request( codex_command: command, codex_cwd: cwd, codex_parsed_cmd, + codex_risk, }; let params_json = match serde_json::to_value(¶ms) { Ok(value) => value, diff --git a/codex-rs/mcp-server/tests/suite/codex_tool.rs b/codex-rs/mcp-server/tests/suite/codex_tool.rs index d7cd200f07..4ab83ca24e 100644 --- a/codex-rs/mcp-server/tests/suite/codex_tool.rs +++ b/codex-rs/mcp-server/tests/suite/codex_tool.rs @@ -196,6 +196,7 @@ fn create_expected_elicitation_request( codex_cwd: workdir.to_path_buf(), codex_call_id: "call1234".to_string(), codex_parsed_cmd, + codex_risk: None, })?), }) } diff --git a/codex-rs/otel/src/otel_event_manager.rs b/codex-rs/otel/src/otel_event_manager.rs index 486683dae0..4006df17d9 100644 --- a/codex-rs/otel/src/otel_event_manager.rs +++ b/codex-rs/otel/src/otel_event_manager.rs @@ -8,6 +8,8 @@ use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SandboxRiskCategory; +use codex_protocol::protocol::SandboxRiskLevel; use codex_protocol::user_input::UserInput; use eventsource_stream::Event as StreamEvent; use eventsource_stream::EventStreamError as StreamError; @@ -366,6 +368,63 @@ impl OtelEventManager { ); } + pub fn sandbox_assessment( + &self, + call_id: &str, + status: &str, + risk_level: Option, + risk_categories: &[SandboxRiskCategory], + duration: Duration, + ) { + let level = risk_level.map(|level| level.as_str()); + let categories = if risk_categories.is_empty() { + String::new() + } else { + risk_categories + .iter() + .map(SandboxRiskCategory::as_str) + .collect::>() + .join(", ") + }; + + tracing::event!( + tracing::Level::INFO, + event.name = "codex.sandbox_assessment", + event.timestamp = %timestamp(), + conversation.id = %self.metadata.conversation_id, + app.version = %self.metadata.app_version, + auth_mode = self.metadata.auth_mode, + user.account_id = self.metadata.account_id, + user.email = self.metadata.account_email, + terminal.type = %self.metadata.terminal_type, + model = %self.metadata.model, + slug = %self.metadata.slug, + call_id = %call_id, + status = %status, + risk_level = level, + risk_categories = categories, + duration_ms = %duration.as_millis(), + ); + } + + pub fn sandbox_assessment_latency(&self, call_id: &str, duration: Duration) { + tracing::event!( + tracing::Level::INFO, + event.name = "codex.sandbox_assessment_latency", + event.timestamp = %timestamp(), + conversation.id = %self.metadata.conversation_id, + app.version = %self.metadata.app_version, + auth_mode = self.metadata.auth_mode, + user.account_id = self.metadata.account_id, + user.email = self.metadata.account_email, + terminal.type = %self.metadata.terminal_type, + model = %self.metadata.model, + slug = %self.metadata.slug, + call_id = %call_id, + duration_ms = %duration.as_millis(), + ); + } + pub async fn log_tool_result( &self, tool_name: &str, diff --git a/codex-rs/protocol/Cargo.toml b/codex-rs/protocol/Cargo.toml index 0393c85427..6249d870a3 100644 --- a/codex-rs/protocol/Cargo.toml +++ b/codex-rs/protocol/Cargo.toml @@ -11,6 +11,8 @@ path = "src/lib.rs" workspace = true [dependencies] +codex-git-tooling = { workspace = true } + base64 = { workspace = true } icu_decimal = { workspace = true } icu_locale_core = { workspace = true } diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs new file mode 100644 index 0000000000..d608dba639 --- /dev/null +++ b/codex-rs/protocol/src/approvals.rs @@ -0,0 +1,91 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use crate::parse_command::ParsedCommand; +use crate::protocol::FileChange; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SandboxRiskLevel { + Low, + Medium, + High, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SandboxRiskCategory { + DataDeletion, + DataExfiltration, + PrivilegeEscalation, + SystemModification, + NetworkAccess, + ResourceExhaustion, + Compliance, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct SandboxCommandAssessment { + pub description: String, + pub risk_level: SandboxRiskLevel, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub risk_categories: Vec, +} + +impl SandboxRiskLevel { + pub fn as_str(&self) -> &'static str { + match self { + Self::Low => "low", + Self::Medium => "medium", + Self::High => "high", + } + } +} + +impl SandboxRiskCategory { + pub fn as_str(&self) -> &'static str { + match self { + Self::DataDeletion => "data_deletion", + Self::DataExfiltration => "data_exfiltration", + Self::PrivilegeEscalation => "privilege_escalation", + Self::SystemModification => "system_modification", + Self::NetworkAccess => "network_access", + Self::ResourceExhaustion => "resource_exhaustion", + Self::Compliance => "compliance", + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +pub struct ExecApprovalRequestEvent { + /// Identifier for the associated exec call, if available. + pub call_id: String, + /// The command to be executed. + pub command: Vec, + /// The command's working directory. + pub cwd: PathBuf, + /// Optional human-readable reason for the approval (e.g. retry without sandbox). + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, + /// Optional model-provided risk assessment describing the blocked command. + #[serde(skip_serializing_if = "Option::is_none")] + pub risk: Option, + pub parsed_cmd: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +pub struct ApplyPatchApprovalRequestEvent { + /// Responses API call id for the associated patch apply call, if available. + pub call_id: String, + pub changes: HashMap, + /// Optional explanatory reason (e.g. request for extra write access). + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, + /// When set, the agent is asking the user to allow writes under this root for the remainder of the session. + #[serde(skip_serializing_if = "Option::is_none")] + pub grant_root: Option, +} diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index 3b8747e63a..08ea753347 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -1,6 +1,7 @@ pub mod account; mod conversation_id; pub use conversation_id::ConversationId; +pub mod approvals; pub mod config_types; pub mod custom_prompts; pub mod items; diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 4e99455bd2..095d7de4de 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -9,6 +9,7 @@ use serde::ser::Serializer; use ts_rs::TS; use crate::user_input::UserInput; +use codex_git_tooling::GhostCommit; use schemars::JsonSchema; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] @@ -116,6 +117,10 @@ pub enum ResponseItem { status: Option, action: WebSearchAction, }, + // Generated by the harness but considered exactly as a model response. + GhostSnapshot { + ghost_commit: GhostCommit, + }, #[serde(other)] Other, } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 105f028049..bb14f3797b 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -34,6 +34,12 @@ use serde_with::serde_as; use strum_macros::Display; use ts_rs::TS; +pub use crate::approvals::ApplyPatchApprovalRequestEvent; +pub use crate::approvals::ExecApprovalRequestEvent; +pub use crate::approvals::SandboxCommandAssessment; +pub use crate::approvals::SandboxRiskCategory; +pub use crate::approvals::SandboxRiskLevel; + /// Open/close tags for special user-input blocks. Used across crates to avoid /// duplicated hardcoded strings. pub const USER_INSTRUCTIONS_OPEN_TAG: &str = ""; @@ -160,10 +166,6 @@ pub enum Op { /// Request a single history entry identified by `log_id` + `offset`. GetHistoryEntryRequest { offset: usize, log_id: u64 }, - /// Request the full in-memory conversation transcript for the current session. - /// Reply is delivered via `EventMsg::ConversationHistory`. - GetPath, - /// Request the list of MCP tools available across all configured servers. /// Reply is delivered via `EventMsg::McpListToolsResponse`. ListMcpTools, @@ -176,6 +178,9 @@ pub enum Op { /// to generate a summary which will be returned as an AgentMessage event. Compact, + /// Request Codex to undo a turn (turn are stacked so it is the same effect as CMD + Z). + Undo, + /// Request a code review from the agent. Review { review_request: ReviewRequest }, @@ -484,6 +489,10 @@ pub enum EventMsg { BackgroundEvent(BackgroundEventEvent), + UndoStarted(UndoStartedEvent), + + UndoCompleted(UndoCompletedEvent), + /// Notification that a model stream experienced an error or disconnect /// and the system is handling it (e.g., retrying with backoff). StreamError(StreamErrorEvent), @@ -513,14 +522,14 @@ pub enum EventMsg { /// Notification that the agent is shutting down. ShutdownComplete, - ConversationPath(ConversationPathResponseEvent), - /// Entered review mode. EnteredReviewMode(ReviewRequest), /// Exited review mode with an optional final result to apply. ExitedReviewMode(ExitedReviewModeEvent), + RawResponseItem(ResponseItem), + ItemStarted(ItemStartedEvent), ItemCompleted(ItemCompletedEvent), } @@ -933,6 +942,7 @@ pub struct SessionMeta { pub instructions: Option, #[serde(default)] pub source: SessionSource, + pub model_provider: Option, } impl Default for SessionMeta { @@ -945,6 +955,7 @@ impl Default for SessionMeta { cli_version: String::new(), instructions: None, source: SessionSource::default(), + model_provider: None, } } } @@ -1126,38 +1137,24 @@ pub struct ExecCommandOutputDeltaEvent { pub chunk: Vec, } -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] -pub struct ExecApprovalRequestEvent { - /// Identifier for the associated exec call, if available. - pub call_id: String, - /// The command to be executed. - pub command: Vec, - /// The command's working directory. - pub cwd: PathBuf, - /// Optional human-readable reason for the approval (e.g. retry without sandbox). - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option, - pub parsed_cmd: Vec, -} - -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] -pub struct ApplyPatchApprovalRequestEvent { - /// Responses API call id for the associated patch apply call, if available. - pub call_id: String, - pub changes: HashMap, - /// Optional explanatory reason (e.g. request for extra write access). - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option, - /// When set, the agent is asking the user to allow writes under this root for the remainder of the session. - #[serde(skip_serializing_if = "Option::is_none")] - pub grant_root: Option, -} - #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct BackgroundEventEvent { pub message: String, } +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +pub struct UndoStartedEvent { + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +pub struct UndoCompletedEvent { + pub success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct StreamErrorEvent { pub message: String, diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 824e4fee31..f087202c22 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -35,7 +35,6 @@ codex-common = { workspace = true, features = [ ] } codex-core = { workspace = true } codex-file-search = { workspace = true } -codex-git-tooling = { workspace = true } codex-login = { workspace = true } codex-ollama = { workspace = true } codex-protocol = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 5a47e40f91..c8eab72969 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -360,6 +360,15 @@ impl App { AppEvent::OpenFullAccessConfirmation { preset } => { self.chat_widget.open_full_access_confirmation(preset); } + AppEvent::OpenFeedbackNote { + category, + include_logs, + } => { + self.chat_widget.open_feedback_note(category, include_logs); + } + AppEvent::OpenFeedbackConsent { category } => { + self.chat_widget.open_feedback_consent(category); + } AppEvent::PersistModelSelection { model, effort } => { let profile = self.active_profile.as_deref(); match persist_model_selection(&self.config.codex_home, profile, &model, effort) diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index b3e948ecb3..e2ded3d3ef 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -103,9 +103,16 @@ impl App { nth_user_message: usize, ) { self.backtrack.pending = Some((base_id, nth_user_message, prefill)); - self.app_event_tx.send(crate::app_event::AppEvent::CodexOp( - codex_core::protocol::Op::GetPath, - )); + if let Some(path) = self.chat_widget.rollout_path() { + let ev = ConversationPathResponseEvent { + conversation_id: base_id, + path, + }; + self.app_event_tx + .send(crate::app_event::AppEvent::ConversationHistory(ev)); + } else { + tracing::error!("rollout path unavailable; cannot backtrack"); + } } /// Open transcript overlay (enters alternate screen and shows full transcript). diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 8592032df3..8b14b0be6f 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -101,4 +101,23 @@ pub(crate) enum AppEvent { /// Open the approval popup. FullScreenApprovalRequest(ApprovalRequest), + + /// Open the feedback note entry overlay after the user selects a category. + OpenFeedbackNote { + category: FeedbackCategory, + include_logs: bool, + }, + + /// Open the upload consent popup for feedback after selecting a category. + OpenFeedbackConsent { + category: FeedbackCategory, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum FeedbackCategory { + BadResult, + GoodResult, + Bug, + Other, } diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index e8ecc084c4..ba36870005 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -19,6 +19,9 @@ use crate::render::renderable::Renderable; use codex_core::protocol::FileChange; use codex_core::protocol::Op; use codex_core::protocol::ReviewDecision; +use codex_core::protocol::SandboxCommandAssessment; +use codex_core::protocol::SandboxRiskCategory; +use codex_core::protocol::SandboxRiskLevel; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -38,6 +41,7 @@ pub(crate) enum ApprovalRequest { id: String, command: Vec, reason: Option, + risk: Option, }, ApplyPatch { id: String, @@ -285,12 +289,17 @@ impl From for ApprovalRequestState { id, command, reason, + risk, } => { + let reason = reason.filter(|item| !item.is_empty()); + let has_reason = reason.is_some(); let mut header: Vec> = Vec::new(); - if let Some(reason) = reason - && !reason.is_empty() - { + if let Some(reason) = reason { header.push(Line::from(vec!["Reason: ".into(), reason.italic()])); + } + if let Some(risk) = risk.as_ref() { + header.extend(render_risk_lines(risk)); + } else if has_reason { header.push(Line::from("")); } let full_cmd = strip_bash_lc_and_escape(&command); @@ -330,6 +339,52 @@ impl From for ApprovalRequestState { } } +fn render_risk_lines(risk: &SandboxCommandAssessment) -> Vec> { + let level_span = match risk.risk_level { + SandboxRiskLevel::Low => "LOW".green().bold(), + SandboxRiskLevel::Medium => "MEDIUM".cyan().bold(), + SandboxRiskLevel::High => "HIGH".red().bold(), + }; + + let mut lines = Vec::new(); + + let description = risk.description.trim(); + if !description.is_empty() { + lines.push(Line::from(vec![ + "Summary: ".into(), + description.to_string().into(), + ])); + } + + let mut spans: Vec> = vec!["Risk: ".into(), level_span]; + if !risk.risk_categories.is_empty() { + spans.push(" (".into()); + for (idx, category) in risk.risk_categories.iter().enumerate() { + if idx > 0 { + spans.push(", ".into()); + } + spans.push(risk_category_label(*category).into()); + } + spans.push(")".into()); + } + + lines.push(Line::from(spans)); + lines.push(Line::from("")); + lines +} + +fn risk_category_label(category: SandboxRiskCategory) -> &'static str { + match category { + SandboxRiskCategory::DataDeletion => "data deletion", + SandboxRiskCategory::DataExfiltration => "data exfiltration", + SandboxRiskCategory::PrivilegeEscalation => "privilege escalation", + SandboxRiskCategory::SystemModification => "system modification", + SandboxRiskCategory::NetworkAccess => "network access", + SandboxRiskCategory::ResourceExhaustion => "resource exhaustion", + SandboxRiskCategory::Compliance => "compliance", + } +} + #[derive(Clone)] enum ApprovalVariant { Exec { id: String, command: Vec }, @@ -404,6 +459,7 @@ mod tests { id: "test".to_string(), command: vec!["echo".to_string(), "hi".to_string()], reason: Some("reason".to_string()), + risk: None, } } @@ -445,6 +501,7 @@ mod tests { id: "test".into(), command, reason: None, + risk: None, }; let view = ApprovalOverlay::new(exec_request, tx); diff --git a/codex-rs/tui/src/bottom_pane/feedback_view.rs b/codex-rs/tui/src/bottom_pane/feedback_view.rs index 9c75e32bdf..0d42c0bebc 100644 --- a/codex-rs/tui/src/bottom_pane/feedback_view.rs +++ b/codex-rs/tui/src/bottom_pane/feedback_view.rs @@ -1,165 +1,446 @@ -use crate::app_event::AppEvent; -use crate::app_event_sender::AppEventSender; -use crate::history_cell; -use crate::history_cell::PlainHistoryCell; -use crate::render::renderable::Renderable; +use std::cell::RefCell; +use std::path::PathBuf; + +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Stylize; use ratatui::text::Line; -use std::path::PathBuf; +use ratatui::text::Span; +use ratatui::widgets::Clear; +use ratatui::widgets::Paragraph; +use ratatui::widgets::StatefulWidgetRef; +use ratatui::widgets::Widget; -use super::BottomPane; -use super::SelectionAction; -use super::SelectionItem; -use super::SelectionViewParams; +use crate::app_event::AppEvent; +use crate::app_event::FeedbackCategory; +use crate::app_event_sender::AppEventSender; +use crate::history_cell; +use crate::render::renderable::Renderable; + +use super::CancellationEvent; +use super::bottom_pane_view::BottomPaneView; +use super::popup_consts::standard_popup_hint_line; +use super::textarea::TextArea; +use super::textarea::TextAreaState; const BASE_ISSUE_URL: &str = "https://github.com/openai/codex/issues/new?template=2-bug-report.yml"; -pub(crate) struct FeedbackView; +/// Minimal input overlay to collect an optional feedback note, then upload +/// both logs and rollout with classification + metadata. +pub(crate) struct FeedbackNoteView { + category: FeedbackCategory, + snapshot: codex_feedback::CodexLogSnapshot, + rollout_path: Option, + app_event_tx: AppEventSender, + include_logs: bool, -impl FeedbackView { - pub fn show( - bottom_pane: &mut BottomPane, - file_path: PathBuf, + // UI state + textarea: TextArea, + textarea_state: RefCell, + complete: bool, +} + +impl FeedbackNoteView { + pub(crate) fn new( + category: FeedbackCategory, snapshot: codex_feedback::CodexLogSnapshot, - ) { - bottom_pane.show_selection_view(Self::selection_params(file_path, snapshot)); + rollout_path: Option, + app_event_tx: AppEventSender, + include_logs: bool, + ) -> Self { + Self { + category, + snapshot, + rollout_path, + app_event_tx, + include_logs, + textarea: TextArea::new(), + textarea_state: RefCell::new(TextAreaState::default()), + complete: false, + } } - fn selection_params( - file_path: PathBuf, - snapshot: codex_feedback::CodexLogSnapshot, - ) -> SelectionViewParams { - let header = FeedbackHeader::new(file_path); + fn submit(&mut self) { + let note = self.textarea.text().trim().to_string(); + let reason_opt = if note.is_empty() { + None + } else { + Some(note.as_str()) + }; + let rollout_path_ref = self.rollout_path.as_deref(); + let classification = feedback_classification(self.category); - let thread_id = snapshot.thread_id.clone(); + let mut thread_id = self.snapshot.thread_id.clone(); - let upload_action_tread_id = thread_id.clone(); - let upload_action: SelectionAction = Box::new(move |tx: &AppEventSender| { - match snapshot.upload_to_sentry() { - Ok(()) => { - let issue_url = format!( - "{BASE_ISSUE_URL}&steps=Uploaded%20thread:%20{upload_action_tread_id}", - ); - tx.send(AppEvent::InsertHistoryCell(Box::new(PlainHistoryCell::new(vec![ - Line::from( - "• Codex logs uploaded. Please open an issue using the following URL:", - ), + let result = self.snapshot.upload_feedback( + classification, + reason_opt, + self.include_logs, + if self.include_logs { + rollout_path_ref + } else { + None + }, + ); + + match result { + Ok(()) => { + let issue_url = format!("{BASE_ISSUE_URL}&steps=Uploaded%20thread:%20{thread_id}"); + let prefix = if self.include_logs { + "• Feedback uploaded." + } else { + "• Feedback recorded (no logs)." + }; + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::PlainHistoryCell::new(vec![ + Line::from(format!( + "{prefix} Please open an issue using the following URL:" + )), "".into(), Line::from(vec![" ".into(), issue_url.cyan().underlined()]), "".into(), - Line::from(vec![" Or mention your thread ID ".into(), upload_action_tread_id.clone().bold(), " in an existing issue.".into()]) - ])))); - } - Err(e) => { - tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_error_event(format!("Failed to upload logs: {e}")), - ))); - } - } - }); - - let upload_item = SelectionItem { - name: "Yes".to_string(), - description: Some( - "Share the current Codex session logs with the team for troubleshooting." - .to_string(), - ), - actions: vec![upload_action], - dismiss_on_select: true, - ..Default::default() - }; - - let no_action: SelectionAction = Box::new(move |tx: &AppEventSender| { - let issue_url = format!("{BASE_ISSUE_URL}&steps=Thread%20ID:%20{thread_id}",); - - tx.send(AppEvent::InsertHistoryCell(Box::new( - PlainHistoryCell::new(vec![ - Line::from("• Please open an issue using the following URL:"), - "".into(), - Line::from(vec![" ".into(), issue_url.cyan().underlined()]), - "".into(), - Line::from(vec![ - " Or mention your thread ID ".into(), - thread_id.clone().bold(), - " in an existing issue.".into(), + Line::from(vec![ + " Or mention your thread ID ".into(), + std::mem::take(&mut thread_id).bold(), + " in an existing issue.".into(), + ]), ]), - ]), - ))); - }); + ))); + } + Err(e) => { + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_error_event(format!("Failed to upload feedback: {e}")), + ))); + } + } + self.complete = true; + } +} - let no_item = SelectionItem { - name: "No".to_string(), - actions: vec![no_action], - dismiss_on_select: true, - ..Default::default() - }; - - let cancel_item = SelectionItem { - name: "Cancel".to_string(), - dismiss_on_select: true, - ..Default::default() - }; - - SelectionViewParams { - header: Box::new(header), - items: vec![upload_item, no_item, cancel_item], - ..Default::default() +impl BottomPaneView for FeedbackNoteView { + fn handle_key_event(&mut self, key_event: KeyEvent) { + match key_event { + KeyEvent { + code: KeyCode::Esc, .. + } => { + self.on_ctrl_c(); + } + KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => { + self.submit(); + } + KeyEvent { + code: KeyCode::Enter, + .. + } => { + self.textarea.input(key_event); + } + other => { + self.textarea.input(other); + } } } -} -struct FeedbackHeader { - file_path: PathBuf, -} - -impl FeedbackHeader { - fn new(file_path: PathBuf) -> Self { - Self { file_path } + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.complete = true; + CancellationEvent::Handled } - fn lines(&self) -> Vec> { - vec![ - Line::from("Do you want to upload logs before reporting issue?".bold()), - "".into(), - Line::from( - "Logs may include the full conversation history of this Codex process, including prompts, tool calls, and their results.", - ), - Line::from( - "These logs are retained for 90 days and are used solely for troubleshooting and diagnostic purposes.", - ), - "".into(), - Line::from(vec![ - "You can review the exact content of the logs before they’re uploaded at:".into(), - ]), - Line::from(self.file_path.display().to_string().dim()), - "".into(), - ] + fn is_complete(&self) -> bool { + self.complete + } + + fn handle_paste(&mut self, pasted: String) -> bool { + if pasted.is_empty() { + return false; + } + self.textarea.insert_str(&pasted); + true + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + if area.height < 2 || area.width <= 2 { + return None; + } + let text_area_height = self.input_height(area.width).saturating_sub(1); + if text_area_height == 0 { + return None; + } + let top_line_count = 1u16; // title only + let textarea_rect = Rect { + x: area.x.saturating_add(2), + y: area.y.saturating_add(top_line_count).saturating_add(1), + width: area.width.saturating_sub(2), + height: text_area_height, + }; + let state = *self.textarea_state.borrow(); + self.textarea.cursor_pos_with_state(textarea_rect, state) } } -impl Renderable for FeedbackHeader { +impl Renderable for FeedbackNoteView { + fn desired_height(&self, width: u16) -> u16 { + 1u16 + self.input_height(width) + 3u16 + } + fn render(&self, area: Rect, buf: &mut Buffer) { - if area.width == 0 || area.height == 0 { + if area.height == 0 || area.width == 0 { return; } - for (i, line) in self.lines().into_iter().enumerate() { - let y = area.y.saturating_add(i as u16); - if y >= area.y.saturating_add(area.height) { - break; + let (title, placeholder) = feedback_title_and_placeholder(self.category); + let input_height = self.input_height(area.width); + + // Title line + let title_area = Rect { + x: area.x, + y: area.y, + width: area.width, + height: 1, + }; + let title_spans: Vec> = vec![gutter(), title.bold()]; + Paragraph::new(Line::from(title_spans)).render(title_area, buf); + + // Input line + let input_area = Rect { + x: area.x, + y: area.y.saturating_add(1), + width: area.width, + height: input_height, + }; + if input_area.width >= 2 { + for row in 0..input_area.height { + Paragraph::new(Line::from(vec![gutter()])).render( + Rect { + x: input_area.x, + y: input_area.y.saturating_add(row), + width: 2, + height: 1, + }, + buf, + ); } - let line_area = Rect::new(area.x, y, area.width, 1).intersection(area); - line.render(line_area, buf); + + let text_area_height = input_area.height.saturating_sub(1); + if text_area_height > 0 { + if input_area.width > 2 { + let blank_rect = Rect { + x: input_area.x.saturating_add(2), + y: input_area.y, + width: input_area.width.saturating_sub(2), + height: 1, + }; + Clear.render(blank_rect, buf); + } + let textarea_rect = Rect { + x: input_area.x.saturating_add(2), + y: input_area.y.saturating_add(1), + width: input_area.width.saturating_sub(2), + height: text_area_height, + }; + let mut state = self.textarea_state.borrow_mut(); + StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); + if self.textarea.text().is_empty() { + Paragraph::new(Line::from(placeholder.dim())).render(textarea_rect, buf); + } + } + } + + let hint_blank_y = input_area.y.saturating_add(input_height); + if hint_blank_y < area.y.saturating_add(area.height) { + let blank_area = Rect { + x: area.x, + y: hint_blank_y, + width: area.width, + height: 1, + }; + Clear.render(blank_area, buf); + } + + let hint_y = hint_blank_y.saturating_add(1); + if hint_y < area.y.saturating_add(area.height) { + Paragraph::new(standard_popup_hint_line()).render( + Rect { + x: area.x, + y: hint_y, + width: area.width, + height: 1, + }, + buf, + ); } } +} - fn desired_height(&self, width: u16) -> u16 { - self.lines() - .iter() - .map(|line| line.desired_height(width)) - .sum() +impl FeedbackNoteView { + fn input_height(&self, width: u16) -> u16 { + let usable_width = width.saturating_sub(2); + let text_height = self.textarea.desired_height(usable_width).clamp(1, 8); + text_height.saturating_add(1).min(9) + } +} + +fn gutter() -> Span<'static> { + "▌ ".cyan() +} + +fn feedback_title_and_placeholder(category: FeedbackCategory) -> (String, String) { + match category { + FeedbackCategory::BadResult => ( + "Tell us more (bad result)".to_string(), + "(optional) Write a short description to help us further".to_string(), + ), + FeedbackCategory::GoodResult => ( + "Tell us more (good result)".to_string(), + "(optional) Write a short description to help us further".to_string(), + ), + FeedbackCategory::Bug => ( + "Tell us more (bug)".to_string(), + "(optional) Write a short description to help us further".to_string(), + ), + FeedbackCategory::Other => ( + "Tell us more (other)".to_string(), + "(optional) Write a short description to help us further".to_string(), + ), + } +} + +fn feedback_classification(category: FeedbackCategory) -> &'static str { + match category { + FeedbackCategory::BadResult => "bad_result", + FeedbackCategory::GoodResult => "good_result", + FeedbackCategory::Bug => "bug", + FeedbackCategory::Other => "other", + } +} + +// Build the selection popup params for feedback categories. +pub(crate) fn feedback_selection_params( + app_event_tx: AppEventSender, +) -> super::SelectionViewParams { + super::SelectionViewParams { + title: Some("How was this?".to_string()), + items: vec![ + make_feedback_item( + app_event_tx.clone(), + "bug", + "Crash, error message, hang, or broken UI/behavior.", + FeedbackCategory::Bug, + ), + make_feedback_item( + app_event_tx.clone(), + "bad result", + "Output was off-target, incorrect, incomplete, or unhelpful.", + FeedbackCategory::BadResult, + ), + make_feedback_item( + app_event_tx.clone(), + "good result", + "Helpful, correct, high‑quality, or delightful result worth celebrating.", + FeedbackCategory::GoodResult, + ), + make_feedback_item( + app_event_tx, + "other", + "Slowness, feature suggestion, UX feedback, or anything else.", + FeedbackCategory::Other, + ), + ], + ..Default::default() + } +} + +fn make_feedback_item( + app_event_tx: AppEventSender, + name: &str, + description: &str, + category: FeedbackCategory, +) -> super::SelectionItem { + let action: super::SelectionAction = Box::new(move |_sender: &AppEventSender| { + app_event_tx.send(AppEvent::OpenFeedbackConsent { category }); + }); + super::SelectionItem { + name: name.to_string(), + description: Some(description.to_string()), + actions: vec![action], + dismiss_on_select: true, + ..Default::default() + } +} + +/// Build the upload consent popup params for a given feedback category. +pub(crate) fn feedback_upload_consent_params( + app_event_tx: AppEventSender, + category: FeedbackCategory, + rollout_path: Option, +) -> super::SelectionViewParams { + use super::popup_consts::standard_popup_hint_line; + let yes_action: super::SelectionAction = Box::new({ + let tx = app_event_tx.clone(); + move |sender: &AppEventSender| { + let _ = sender; + tx.send(AppEvent::OpenFeedbackNote { + category, + include_logs: true, + }); + } + }); + + let no_action: super::SelectionAction = Box::new({ + let tx = app_event_tx; + move |sender: &AppEventSender| { + let _ = sender; + tx.send(AppEvent::OpenFeedbackNote { + category, + include_logs: false, + }); + } + }); + + // Build header listing files that would be sent if user consents. + let mut header_lines: Vec> = vec![ + Line::from("Upload logs?".bold()).into(), + Line::from("").into(), + Line::from("The following files will be sent:".dim()).into(), + Line::from(vec![" • ".into(), "codex-logs.log".into()]).into(), + ]; + if let Some(path) = rollout_path.as_deref() + && let Some(name) = path.file_name().map(|s| s.to_string_lossy().to_string()) + { + header_lines.push(Line::from(vec![" • ".into(), name.into()]).into()); + } + + super::SelectionViewParams { + footer_hint: Some(standard_popup_hint_line()), + items: vec![ + super::SelectionItem { + name: "Yes".to_string(), + description: Some( + "Share the current Codex session logs with the team for troubleshooting." + .to_string(), + ), + actions: vec![yes_action], + dismiss_on_select: true, + ..Default::default() + }, + super::SelectionItem { + name: "No".to_string(), + description: Some("".to_string()), + actions: vec![no_action], + dismiss_on_select: true, + ..Default::default() + }, + ], + header: Box::new(crate::render::renderable::ColumnRenderable::with( + header_lines, + )), + ..Default::default() } } @@ -167,22 +448,19 @@ impl Renderable for FeedbackHeader { mod tests { use super::*; use crate::app_event::AppEvent; - use crate::bottom_pane::list_selection_view::ListSelectionView; - use crate::style::user_message_style; - use codex_feedback::CodexFeedback; - use codex_protocol::ConversationId; - use insta::assert_snapshot; - use ratatui::buffer::Buffer; - use ratatui::layout::Rect; - use ratatui::style::Color; - use tokio::sync::mpsc::unbounded_channel; + use crate::app_event_sender::AppEventSender; - fn buffer_to_string(buffer: &Buffer) -> String { - (0..buffer.area.height) + fn render(view: &FeedbackNoteView, width: u16) -> String { + let height = view.desired_height(width); + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + + let mut lines: Vec = (0..area.height) .map(|row| { let mut line = String::new(); - for col in 0..buffer.area.width { - let symbol = buffer[(buffer.area.x + col, buffer.area.y + row)].symbol(); + for col in 0..area.width { + let symbol = buf[(area.x + col, area.y + row)].symbol(); if symbol.is_empty() { line.push(' '); } else { @@ -191,34 +469,49 @@ mod tests { } line.trim_end().to_string() }) - .collect::>() - .join("\n") + .collect(); + + while lines.first().is_some_and(|l| l.trim().is_empty()) { + lines.remove(0); + } + while lines.last().is_some_and(|l| l.trim().is_empty()) { + lines.pop(); + } + lines.join("\n") + } + + fn make_view(category: FeedbackCategory) -> FeedbackNoteView { + let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let snapshot = codex_feedback::CodexFeedback::new().snapshot(None); + FeedbackNoteView::new(category, snapshot, None, tx, true) } #[test] - fn renders_feedback_view_header() { - let (tx_raw, _rx) = unbounded_channel::(); - let app_event_tx = AppEventSender::new(tx_raw); - let snapshot = CodexFeedback::new().snapshot(Some( - ConversationId::from_string("550e8400-e29b-41d4-a716-446655440000").unwrap(), - )); - let file_path = PathBuf::from("/tmp/codex-feedback.log"); + fn feedback_view_bad_result() { + let view = make_view(FeedbackCategory::BadResult); + let rendered = render(&view, 60); + insta::assert_snapshot!("feedback_view_bad_result", rendered); + } - let params = FeedbackView::selection_params(file_path.clone(), snapshot); - let view = ListSelectionView::new(params, app_event_tx); + #[test] + fn feedback_view_good_result() { + let view = make_view(FeedbackCategory::GoodResult); + let rendered = render(&view, 60); + insta::assert_snapshot!("feedback_view_good_result", rendered); + } - let width = 72; - let height = view.desired_height(width).max(1); - let area = Rect::new(0, 0, width, height); - let mut buf = Buffer::empty(area); - view.render(area, &mut buf); + #[test] + fn feedback_view_bug() { + let view = make_view(FeedbackCategory::Bug); + let rendered = render(&view, 60); + insta::assert_snapshot!("feedback_view_bug", rendered); + } - let rendered = - buffer_to_string(&buf).replace(&file_path.display().to_string(), ""); - assert_snapshot!("feedback_view_render", rendered); - - let cell_style = buf[(area.x, area.y)].style(); - let expected_bg = user_message_style().bg.unwrap_or(Color::Reset); - assert_eq!(cell_style.bg.unwrap_or(Color::Reset), expected_bg); + #[test] + fn feedback_view_other() { + let view = make_view(FeedbackCategory::Other); + let rendered = render(&view, 60); + insta::assert_snapshot!("feedback_view_other", rendered); } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 69405cd823..c23709fe07 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -28,12 +28,14 @@ mod list_selection_view; mod prompt_args; pub(crate) use list_selection_view::SelectionViewParams; mod feedback_view; +pub(crate) use feedback_view::feedback_selection_params; +pub(crate) use feedback_view::feedback_upload_consent_params; mod paste_burst; pub mod popup_consts; mod scroll_state; mod selection_popup_common; mod textarea; -pub(crate) use feedback_view::FeedbackView; +pub(crate) use feedback_view::FeedbackNoteView; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum CancellationEvent { @@ -313,6 +315,11 @@ impl BottomPane { self.ctrl_c_quit_hint } + #[cfg(test)] + pub(crate) fn status_indicator_visible(&self) -> bool { + self.status.is_some() + } + pub(crate) fn show_esc_backtrack_hint(&mut self) { self.esc_backtrack_hint = true; self.composer.set_esc_backtrack_hint(true); @@ -341,6 +348,7 @@ impl BottomPane { )); } if let Some(status) = self.status.as_mut() { + status.set_interrupt_hint_visible(true); status.set_queued_messages(self.queued_user_messages.clone()); } self.request_redraw(); @@ -357,6 +365,23 @@ impl BottomPane { } } + pub(crate) fn ensure_status_indicator(&mut self) { + if self.status.is_none() { + self.status = Some(StatusIndicatorWidget::new( + self.app_event_tx.clone(), + self.frame_requester.clone(), + )); + self.request_redraw(); + } + } + + pub(crate) fn set_interrupt_hint_visible(&mut self, visible: bool) { + if let Some(status) = self.status.as_mut() { + status.set_interrupt_hint_visible(visible); + self.request_redraw(); + } + } + pub(crate) fn set_context_window_percent(&mut self, percent: Option) { if self.context_window_percent == percent { return; @@ -557,6 +582,7 @@ mod tests { id: "1".to_string(), command: vec!["echo".into(), "ok".into()], reason: None, + risk: None, } } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_bad_result.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_bad_result.snap new file mode 100644 index 0000000000..465f0f9c4f --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_bad_result.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/feedback_view.rs +expression: rendered +--- +▌ Tell us more (bad result) +▌ +▌ (optional) Write a short description to help us further + +Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_bug.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_bug.snap new file mode 100644 index 0000000000..a0b5660135 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_bug.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/feedback_view.rs +expression: rendered +--- +▌ Tell us more (bug) +▌ +▌ (optional) Write a short description to help us further + +Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_good_result.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_good_result.snap new file mode 100644 index 0000000000..73074d61fa --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_good_result.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/feedback_view.rs +expression: rendered +--- +▌ Tell us more (good result) +▌ +▌ (optional) Write a short description to help us further + +Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_other.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_other.snap new file mode 100644 index 0000000000..80e4ffeffe --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_other.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/feedback_view.rs +expression: rendered +--- +▌ Tell us more (other) +▌ +▌ (optional) Write a short description to help us further + +Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c1a6dd6769..13411df404 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -37,6 +37,8 @@ use codex_core::protocol::TokenUsage; use codex_core::protocol::TokenUsageInfo; use codex_core::protocol::TurnAbortReason; use codex_core::protocol::TurnDiffEvent; +use codex_core::protocol::UndoCompletedEvent; +use codex_core::protocol::UndoStartedEvent; use codex_core::protocol::UserMessageEvent; use codex_core::protocol::ViewImageToolCallEvent; use codex_core::protocol::WebSearchBeginEvent; @@ -113,16 +115,9 @@ use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; use codex_file_search::FileMatch; -use codex_git_tooling::CreateGhostCommitOptions; -use codex_git_tooling::GhostCommit; -use codex_git_tooling::GitToolingError; -use codex_git_tooling::create_ghost_commit; -use codex_git_tooling::restore_ghost_commit; use codex_protocol::plan_tool::UpdatePlanArgs; use strum::IntoEnumIterator; -const MAX_TRACKED_GHOST_COMMITS: usize = 20; - // Track information about an in-flight exec command. struct RunningCommand { command: Vec, @@ -267,15 +262,14 @@ pub(crate) struct ChatWidget { pending_notification: Option, // Simple review mode flag; used to adjust layout and banners. is_review_mode: bool, - // List of ghost commits corresponding to each turn. - ghost_snapshots: Vec, - ghost_snapshots_disabled: bool, // Whether to add a final message separator after the last message needs_final_message_separator: bool, last_rendered_width: std::cell::Cell>, // Feedback sink for /feedback feedback: codex_feedback::CodexFeedback, + // Current session rollout path (if known) + current_rollout_path: Option, } struct UserMessage { @@ -310,9 +304,6 @@ impl ChatWidget { } fn set_status_header(&mut self, header: String) { - if self.current_status_header == header { - return; - } self.current_status_header = header.clone(); self.bottom_pane.update_status_header(header); } @@ -322,6 +313,7 @@ impl ChatWidget { self.bottom_pane .set_history_metadata(event.history_log_id, event.history_entry_count); self.conversation_id = Some(event.session_id); + self.current_rollout_path = Some(event.rollout_path.clone()); let initial_messages = event.initial_messages.clone(); let model_for_header = event.model.clone(); self.session_header.set_model(&model_for_header); @@ -343,6 +335,39 @@ impl ChatWidget { } } + pub(crate) fn open_feedback_note( + &mut self, + category: crate::app_event::FeedbackCategory, + include_logs: bool, + ) { + // Build a fresh snapshot at the time of opening the note overlay. + let snapshot = self.feedback.snapshot(self.conversation_id); + let rollout = if include_logs { + self.current_rollout_path.clone() + } else { + None + }; + let view = crate::bottom_pane::FeedbackNoteView::new( + category, + snapshot, + rollout, + self.app_event_tx.clone(), + include_logs, + ); + self.bottom_pane.show_view(Box::new(view)); + self.request_redraw(); + } + + pub(crate) fn open_feedback_consent(&mut self, category: crate::app_event::FeedbackCategory) { + let params = crate::bottom_pane::feedback_upload_consent_params( + self.app_event_tx.clone(), + category, + self.current_rollout_path.clone(), + ); + self.bottom_pane.show_selection_view(params); + self.request_redraw(); + } + fn on_agent_message(&mut self, message: String) { // If we have a stream_controller, then the final agent message is redundant and will be a // duplicate of what has already been streamed. @@ -401,6 +426,7 @@ impl ChatWidget { self.bottom_pane.clear_ctrl_c_quit_hint(); self.bottom_pane.set_task_running(true); self.retry_status_header = None; + self.bottom_pane.set_interrupt_hint_visible(true); self.set_status_header(String::from("Working")); self.full_reasoning_buffer.clear(); self.reasoning_buffer.clear(); @@ -496,7 +522,7 @@ impl ChatWidget { if reason != TurnAbortReason::ReviewEnded { self.add_to_history(history_cell::new_error_event( - "Conversation interrupted - tell the model what to do differently".to_owned(), + "Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue.".to_owned(), )); } @@ -636,6 +662,32 @@ impl ChatWidget { debug!("BackgroundEvent: {message}"); } + fn on_undo_started(&mut self, event: UndoStartedEvent) { + self.bottom_pane.ensure_status_indicator(); + self.bottom_pane.set_interrupt_hint_visible(false); + let message = event + .message + .unwrap_or_else(|| "Undo in progress...".to_string()); + self.set_status_header(message); + } + + fn on_undo_completed(&mut self, event: UndoCompletedEvent) { + let UndoCompletedEvent { success, message } = event; + self.bottom_pane.hide_status_indicator(); + let message = message.unwrap_or_else(|| { + if success { + "Undo completed successfully.".to_string() + } else { + "Undo failed.".to_string() + } + }); + if success { + self.add_info_message(message, None); + } else { + self.add_error_message(message); + } + } + fn on_stream_error(&mut self, message: String) { if self.retry_status_header.is_none() { self.retry_status_header = Some(self.current_status_header.clone()); @@ -745,9 +797,8 @@ impl ChatWidget { &ev.call_id, CommandOutput { exit_code: ev.exit_code, - stdout: ev.stdout.clone(), - stderr: ev.stderr.clone(), formatted_output: ev.formatted_output.clone(), + aggregated_output: ev.aggregated_output.clone(), }, ev.duration, ); @@ -778,6 +829,7 @@ impl ChatWidget { id, command: ev.command, reason: ev.reason, + risk: ev.risk, }; self.bottom_pane.push_approval_request(request); self.request_redraw(); @@ -953,11 +1005,10 @@ impl ChatWidget { suppress_session_configured_redraw: false, pending_notification: None, is_review_mode: false, - ghost_snapshots: Vec::new(), - ghost_snapshots_disabled: true, needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), feedback, + current_rollout_path: None, } } @@ -1020,11 +1071,10 @@ impl ChatWidget { suppress_session_configured_redraw: true, pending_notification: None, is_review_mode: false, - ghost_snapshots: Vec::new(), - ghost_snapshots_disabled: true, needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), feedback, + current_rollout_path: None, } } @@ -1129,23 +1179,11 @@ impl ChatWidget { } match cmd { SlashCommand::Feedback => { - let snapshot = self.feedback.snapshot(self.conversation_id); - match snapshot.save_to_temp_file() { - Ok(path) => { - crate::bottom_pane::FeedbackView::show( - &mut self.bottom_pane, - path, - snapshot, - ); - self.request_redraw(); - } - Err(e) => { - self.add_to_history(history_cell::new_error_event(format!( - "Failed to save feedback logs: {e}" - ))); - self.request_redraw(); - } - } + // Step 1: pick a category (UI built in feedback_view) + let params = + crate::bottom_pane::feedback_selection_params(self.app_event_tx.clone()); + self.bottom_pane.show_selection_view(params); + self.request_redraw(); } SlashCommand::New => { self.app_event_tx.send(AppEvent::NewSession); @@ -1185,7 +1223,7 @@ impl ChatWidget { self.app_event_tx.send(AppEvent::ExitRequest); } SlashCommand::Undo => { - self.undo_last_snapshot(); + self.app_event_tx.send(AppEvent::CodexOp(Op::Undo)); } SlashCommand::Diff => { self.add_diff_in_progress(); @@ -1302,8 +1340,6 @@ impl ChatWidget { return; } - self.capture_ghost_snapshot(); - let mut items: Vec = Vec::new(); if !text.is_empty() { @@ -1336,57 +1372,6 @@ impl ChatWidget { self.needs_final_message_separator = false; } - fn capture_ghost_snapshot(&mut self) { - if self.ghost_snapshots_disabled { - return; - } - - let options = CreateGhostCommitOptions::new(&self.config.cwd); - match create_ghost_commit(&options) { - Ok(commit) => { - self.ghost_snapshots.push(commit); - if self.ghost_snapshots.len() > MAX_TRACKED_GHOST_COMMITS { - self.ghost_snapshots.remove(0); - } - } - Err(err) => { - self.ghost_snapshots_disabled = true; - let (message, hint) = match &err { - GitToolingError::NotAGitRepository { .. } => ( - "Snapshots disabled: current directory is not a Git repository." - .to_string(), - None, - ), - _ => ( - format!("Snapshots disabled after error: {err}"), - Some( - "Restart Codex after resolving the issue to re-enable snapshots." - .to_string(), - ), - ), - }; - self.add_info_message(message, hint); - tracing::warn!("failed to create ghost snapshot: {err}"); - } - } - } - - fn undo_last_snapshot(&mut self) { - let Some(commit) = self.ghost_snapshots.pop() else { - self.add_info_message("No snapshot available to undo.".to_string(), None); - return; - }; - - if let Err(err) = restore_ghost_commit(&self.config.cwd, &commit) { - self.add_error_message(format!("Failed to restore snapshot: {err}")); - self.ghost_snapshots.push(commit); - return; - } - - let short_id: String = commit.id().chars().take(8).collect(); - self.add_info_message(format!("Restored workspace to snapshot {short_id}"), None); - } - /// Replay a subset of initial events into the UI to seed the transcript when /// resuming an existing session. This approximates the live event flow and /// is intentionally conservative: only safe-to-replay items are rendered to @@ -1484,21 +1469,21 @@ impl ChatWidget { EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => { self.on_background_event(message) } + EventMsg::UndoStarted(ev) => self.on_undo_started(ev), + EventMsg::UndoCompleted(ev) => self.on_undo_completed(ev), EventMsg::StreamError(StreamErrorEvent { message }) => self.on_stream_error(message), EventMsg::UserMessage(ev) => { if from_replay { self.on_user_message_event(ev); } } - EventMsg::ConversationPath(ev) => { - self.app_event_tx - .send(crate::app_event::AppEvent::ConversationHistory(ev)); - } EventMsg::EnteredReviewMode(review_request) => { self.on_entered_review_mode(review_request) } EventMsg::ExitedReviewMode(review) => self.on_exited_review_mode(review), - EventMsg::ItemStarted(_) | EventMsg::ItemCompleted(_) => {} + EventMsg::RawResponseItem(_) + | EventMsg::ItemStarted(_) + | EventMsg::ItemCompleted(_) => {} } } @@ -1632,6 +1617,7 @@ impl ChatWidget { context_usage, &self.conversation_id, self.rate_limit_snapshot.as_ref(), + Local::now(), )); } @@ -2221,6 +2207,10 @@ impl ChatWidget { self.conversation_id } + pub(crate) fn rollout_path(&self) -> Option { + self.current_rollout_path.clone() + } + /// Return a reference to the widget's current config (includes any /// runtime overrides applied via TUI, e.g., model or approval policy). pub(crate) fn config_ref(&self) -> &Config { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_selection_popup.snap new file mode 100644 index 0000000000..4a98242027 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_selection_popup.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + How was this? + +› 1. bug Crash, error message, hang, or broken UI/behavior. + 2. bad result Output was off-target, incorrect, incomplete, or unhelpful. + 3. good result Helpful, correct, high‑quality, or delightful result worth + celebrating. + 4. other Slowness, feature suggestion, UX feedback, or anything else. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap new file mode 100644 index 0000000000..cc3d8e3755 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Upload logs? + + The following files will be sent: + • codex-logs.log + +› 1. Yes Share the current Codex session logs with the team for + troubleshooting. + 2. No + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupted_turn_error_message.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupted_turn_error_message.snap new file mode 100644 index 0000000000..60715e581e --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupted_turn_error_message.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: last +--- +■ Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue. diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 60f1ab047a..75796d7d62 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -34,6 +34,8 @@ use codex_core::protocol::ReviewRequest; use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TaskStartedEvent; +use codex_core::protocol::UndoCompletedEvent; +use codex_core::protocol::UndoStartedEvent; use codex_core::protocol::ViewImageToolCallEvent; use codex_protocol::ConversationId; use codex_protocol::plan_tool::PlanItemArg; @@ -71,18 +73,26 @@ fn upgrade_event_payload_for_tests(mut payload: serde_json::Value) -> serde_json && let Some(m) = msg.as_object_mut() { let ty = m.get("type").and_then(|v| v.as_str()).unwrap_or(""); - if ty == "exec_command_end" && !m.contains_key("formatted_output") { + if ty == "exec_command_end" { let stdout = m.get("stdout").and_then(|v| v.as_str()).unwrap_or(""); let stderr = m.get("stderr").and_then(|v| v.as_str()).unwrap_or(""); - let formatted = if stderr.is_empty() { + let aggregated = if stderr.is_empty() { stdout.to_string() } else { format!("{stdout}{stderr}") }; - m.insert( - "formatted_output".to_string(), - serde_json::Value::String(formatted), - ); + if !m.contains_key("formatted_output") { + m.insert( + "formatted_output".to_string(), + serde_json::Value::String(aggregated.clone()), + ); + } + if !m.contains_key("aggregated_output") { + m.insert( + "aggregated_output".to_string(), + serde_json::Value::String(aggregated), + ); + } } } payload @@ -286,11 +296,10 @@ fn make_chatwidget_manual() -> ( suppress_session_configured_redraw: false, pending_notification: None, is_review_mode: false, - ghost_snapshots: Vec::new(), - ghost_snapshots_disabled: false, needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), feedback: codex_feedback::CodexFeedback::new(), + current_rollout_path: None, }; (widget, rx, op_rx) } @@ -394,6 +403,7 @@ fn exec_approval_emits_proposed_command_and_decision_history() { reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), + risk: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -436,6 +446,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() { reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), + risk: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -484,6 +495,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() { command: vec!["bash".into(), "-lc".into(), long], cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), reason: None, + risk: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -837,6 +849,109 @@ fn slash_init_skips_when_project_doc_exists() { ); } +#[test] +fn slash_undo_sends_op() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + + chat.dispatch_command(SlashCommand::Undo); + + match rx.try_recv() { + Ok(AppEvent::CodexOp(Op::Undo)) => {} + other => panic!("expected AppEvent::CodexOp(Op::Undo), got {other:?}"), + } +} + +#[test] +fn undo_success_events_render_info_messages() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + + chat.handle_codex_event(Event { + id: "turn-1".to_string(), + msg: EventMsg::UndoStarted(UndoStartedEvent { + message: Some("Undo requested for the last turn...".to_string()), + }), + }); + assert!( + chat.bottom_pane.status_indicator_visible(), + "status indicator should be visible during undo" + ); + + chat.handle_codex_event(Event { + id: "turn-1".to_string(), + msg: EventMsg::UndoCompleted(UndoCompletedEvent { + success: true, + message: None, + }), + }); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected final status only"); + assert!( + !chat.bottom_pane.status_indicator_visible(), + "status indicator should be hidden after successful undo" + ); + + let completed = lines_to_single_string(&cells[0]); + assert!( + completed.contains("Undo completed successfully."), + "expected default success message, got {completed:?}" + ); +} + +#[test] +fn undo_failure_events_render_error_message() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + + chat.handle_codex_event(Event { + id: "turn-2".to_string(), + msg: EventMsg::UndoStarted(UndoStartedEvent { message: None }), + }); + assert!( + chat.bottom_pane.status_indicator_visible(), + "status indicator should be visible during undo" + ); + + chat.handle_codex_event(Event { + id: "turn-2".to_string(), + msg: EventMsg::UndoCompleted(UndoCompletedEvent { + success: false, + message: Some("Failed to restore workspace state.".to_string()), + }), + }); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected final status only"); + assert!( + !chat.bottom_pane.status_indicator_visible(), + "status indicator should be hidden after failed undo" + ); + + let completed = lines_to_single_string(&cells[0]); + assert!( + completed.contains("Failed to restore workspace state."), + "expected failure message, got {completed:?}" + ); +} + +#[test] +fn undo_started_hides_interrupt_hint() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + + chat.handle_codex_event(Event { + id: "turn-hint".to_string(), + msg: EventMsg::UndoStarted(UndoStartedEvent { message: None }), + }); + + let status = chat + .bottom_pane + .status_widget() + .expect("status indicator should be active"); + assert!( + !status.interrupt_hint_visible(), + "undo should hide the interrupt hint because the operation cannot be cancelled" + ); +} + /// The commit picker shows only commit subjects (no timestamps). #[test] fn review_commit_picker_shows_subjects_without_timestamps() { @@ -987,6 +1102,37 @@ fn interrupt_exec_marks_failed_snapshot() { assert_snapshot!("interrupt_exec_marks_failed", exec_blob); } +// Snapshot test: after an interrupted turn, a gentle error message is inserted +// suggesting the user to tell the model what to do differently and to use /feedback. +#[test] +fn interrupted_turn_error_message_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + + // Simulate an in-progress task so the widget is in a running state. + chat.handle_codex_event(Event { + id: "task-1".into(), + msg: EventMsg::TaskStarted(TaskStartedEvent { + model_context_window: None, + }), + }); + + // Abort the turn (like pressing Esc) and drain inserted history. + chat.handle_codex_event(Event { + id: "task-1".into(), + msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { + reason: TurnAbortReason::Interrupted, + }), + }); + + let cells = drain_insert_history(&mut rx); + assert!( + !cells.is_empty(), + "expected error message to be inserted after interruption" + ); + let last = lines_to_single_string(cells.last().unwrap()); + assert_snapshot!("interrupted_turn_error_message", last); +} + /// Opening custom prompt from the review popup, pressing Esc returns to the /// parent popup, pressing Esc again dismisses all panels (back to normal mode). #[test] @@ -1164,6 +1310,28 @@ fn model_reasoning_selection_popup_snapshot() { assert_snapshot!("model_reasoning_selection_popup", popup); } +#[test] +fn feedback_selection_popup_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + + // Open the feedback category selection popup via slash command. + chat.dispatch_command(SlashCommand::Feedback); + + let popup = render_bottom_popup(&chat, 80); + assert_snapshot!("feedback_selection_popup", popup); +} + +#[test] +fn feedback_upload_consent_popup_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + + // Open the consent popup directly for a chosen category. + chat.open_feedback_consent(crate::app_event::FeedbackCategory::Bug); + + let popup = render_bottom_popup(&chat, 80); + assert_snapshot!("feedback_upload_consent_popup", popup); +} + #[test] fn reasoning_popup_escape_returns_to_model_popup() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); @@ -1413,6 +1581,7 @@ fn approval_modal_exec_snapshot() { reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), + risk: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -1457,6 +1626,7 @@ fn approval_modal_exec_without_reason_snapshot() { command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), reason: None, + risk: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -1667,6 +1837,7 @@ fn status_widget_and_approval_modal_snapshot() { reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), + risk: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { diff --git a/codex-rs/tui/src/exec_cell/model.rs b/codex-rs/tui/src/exec_cell/model.rs index 2893a005dc..cf9acc5465 100644 --- a/codex-rs/tui/src/exec_cell/model.rs +++ b/codex-rs/tui/src/exec_cell/model.rs @@ -3,11 +3,12 @@ use std::time::Instant; use codex_protocol::parse_command::ParsedCommand; -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Default)] pub(crate) struct CommandOutput { pub(crate) exit_code: i32, - pub(crate) stdout: String, - pub(crate) stderr: String, + /// The aggregated stderr + stdout interleaved. + pub(crate) aggregated_output: String, + /// The formatted output of the command, as seen by the model. pub(crate) formatted_output: String, } @@ -82,9 +83,8 @@ impl ExecCell { call.duration = Some(elapsed); call.output = Some(CommandOutput { exit_code: 1, - stdout: String::new(), - stderr: String::new(), formatted_output: String::new(), + aggregated_output: String::new(), }); } } diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs index 98d9d07256..bc336b98e8 100644 --- a/codex-rs/tui/src/exec_cell/render.rs +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -28,7 +28,6 @@ use unicode_width::UnicodeWidthStr; pub(crate) const TOOL_CALL_MAX_LINES: usize = 5; pub(crate) struct OutputLinesParams { - pub(crate) only_err: bool, pub(crate) include_angle_pipe: bool, pub(crate) include_prefix: bool, } @@ -59,22 +58,12 @@ pub(crate) fn output_lines( params: OutputLinesParams, ) -> OutputLines { let OutputLinesParams { - only_err, include_angle_pipe, include_prefix, } = params; let CommandOutput { - exit_code, - stdout, - stderr, - .. + aggregated_output, .. } = match output { - Some(output) if only_err && output.exit_code == 0 => { - return OutputLines { - lines: Vec::new(), - omitted: None, - }; - } Some(output) => output, None => { return OutputLines { @@ -84,7 +73,7 @@ pub(crate) fn output_lines( } }; - let src = if *exit_code == 0 { stdout } else { stderr }; + let src = aggregated_output; let lines: Vec<&str> = src.lines().collect(); let total = lines.len(); let limit = TOOL_CALL_MAX_LINES; @@ -398,7 +387,6 @@ impl ExecCell { let raw_output = output_lines( Some(output), OutputLinesParams { - only_err: false, include_angle_pipe: false, include_prefix: false, }, diff --git a/codex-rs/tui/src/file_search.rs b/codex-rs/tui/src/file_search.rs index 7f15e46abb..61327cfd24 100644 --- a/codex-rs/tui/src/file_search.rs +++ b/codex-rs/tui/src/file_search.rs @@ -172,6 +172,7 @@ impl FileSearchManager { NUM_FILE_SEARCH_THREADS, cancellation_token.clone(), compute_indices, + true, ) .map(|res| res.matches) .unwrap_or_default(); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index fe85fb8716..e630e00175 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1047,7 +1047,10 @@ pub(crate) fn new_mcp_tools_output( return PlainHistoryCell { lines }; } - for (server, cfg) in config.mcp_servers.iter() { + let mut servers: Vec<_> = config.mcp_servers.iter().collect(); + servers.sort_by(|(a, _), (b, _)| a.cmp(b)); + + for (server, cfg) in servers { let prefix = format!("mcp__{server}__"); let mut names: Vec = tools .keys() @@ -1111,7 +1114,7 @@ pub(crate) fn new_mcp_tools_output( pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); let display = pairs .into_iter() - .map(|(name, value)| format!("{name}={value}")) + .map(|(name, _)| format!("{name}=*****")) .collect::>() .join(", "); lines.push(vec![" • HTTP headers: ".into(), display.into()].into()); @@ -1123,7 +1126,7 @@ pub(crate) fn new_mcp_tools_output( pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); let display = pairs .into_iter() - .map(|(name, env_var)| format!("{name}={env_var}")) + .map(|(name, var)| format!("{name}={var}")) .collect::>() .join(", "); lines.push(vec![" • Env HTTP headers: ".into(), display.into()].into()); @@ -1293,12 +1296,10 @@ pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell { let output = output_lines( Some(&CommandOutput { exit_code: 1, - stdout: String::new(), - stderr, formatted_output: String::new(), + aggregated_output: stderr, }), OutputLinesParams { - only_err: true, include_angle_pipe: true, include_prefix: true, }, @@ -1417,14 +1418,20 @@ mod tests { use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; + use codex_core::config_types::McpServerConfig; + use codex_core::config_types::McpServerTransportConfig; + use codex_core::protocol::McpAuthStatus; use codex_protocol::parse_command::ParsedCommand; use dirs::home_dir; use pretty_assertions::assert_eq; use serde_json::json; + use std::collections::HashMap; use mcp_types::CallToolResult; use mcp_types::ContentBlock; use mcp_types::TextContent; + use mcp_types::Tool; + use mcp_types::ToolInputSchema; fn test_config() -> Config { Config::load_from_base_config_with_overrides( @@ -1451,6 +1458,91 @@ mod tests { render_lines(&cell.transcript_lines(u16::MAX)) } + #[test] + fn mcp_tools_output_masks_sensitive_values() { + let mut config = test_config(); + let mut env = HashMap::new(); + env.insert("TOKEN".to_string(), "secret".to_string()); + let stdio_config = McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "docs-server".to_string(), + args: vec![], + env: Some(env), + env_vars: vec!["APP_TOKEN".to_string()], + cwd: None, + }, + enabled: true, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + }; + config.mcp_servers.insert("docs".to_string(), stdio_config); + + let mut headers = HashMap::new(); + headers.insert("Authorization".to_string(), "Bearer secret".to_string()); + let mut env_headers = HashMap::new(); + env_headers.insert("X-API-Key".to_string(), "API_KEY_ENV".to_string()); + let http_config = McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".to_string(), + bearer_token_env_var: Some("MCP_TOKEN".to_string()), + http_headers: Some(headers), + env_http_headers: Some(env_headers), + }, + enabled: true, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + }; + config.mcp_servers.insert("http".to_string(), http_config); + + let mut tools: HashMap = HashMap::new(); + tools.insert( + "mcp__docs__list".to_string(), + Tool { + annotations: None, + description: None, + input_schema: ToolInputSchema { + properties: None, + required: None, + r#type: "object".to_string(), + }, + name: "list".to_string(), + output_schema: None, + title: None, + }, + ); + tools.insert( + "mcp__http__ping".to_string(), + Tool { + annotations: None, + description: None, + input_schema: ToolInputSchema { + properties: None, + required: None, + r#type: "object".to_string(), + }, + name: "ping".to_string(), + output_schema: None, + title: None, + }, + ); + + let auth_statuses: HashMap = HashMap::new(); + let cell = new_mcp_tools_output( + &config, + tools, + HashMap::new(), + HashMap::new(), + &auth_statuses, + ); + let rendered = render_lines(&cell.display_lines(120)).join("\n"); + + insta::assert_snapshot!(rendered); + } + #[test] fn empty_agent_message_cell_transcript() { let cell = AgentMessageCell::new(vec![Line::default()], false); @@ -1739,16 +1831,7 @@ mod tests { duration: None, }); // Mark call complete so markers are ✓ - cell.complete_call( - &call_id, - CommandOutput { - exit_code: 0, - stdout: String::new(), - stderr: String::new(), - formatted_output: String::new(), - }, - Duration::from_millis(1), - ); + cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1)); let lines = cell.display_lines(80); let rendered = render_lines(&lines).join("\n"); @@ -1770,16 +1853,7 @@ mod tests { duration: None, }); // Call 1: Search only - cell.complete_call( - "c1", - CommandOutput { - exit_code: 0, - stdout: String::new(), - stderr: String::new(), - formatted_output: String::new(), - }, - Duration::from_millis(1), - ); + cell.complete_call("c1", CommandOutput::default(), Duration::from_millis(1)); // Call 2: Read A cell = cell .with_added_call( @@ -1792,16 +1866,7 @@ mod tests { }], ) .unwrap(); - cell.complete_call( - "c2", - CommandOutput { - exit_code: 0, - stdout: String::new(), - stderr: String::new(), - formatted_output: String::new(), - }, - Duration::from_millis(1), - ); + cell.complete_call("c2", CommandOutput::default(), Duration::from_millis(1)); // Call 3: Read B cell = cell .with_added_call( @@ -1814,16 +1879,7 @@ mod tests { }], ) .unwrap(); - cell.complete_call( - "c3", - CommandOutput { - exit_code: 0, - stdout: String::new(), - stderr: String::new(), - formatted_output: String::new(), - }, - Duration::from_millis(1), - ); + cell.complete_call("c3", CommandOutput::default(), Duration::from_millis(1)); let lines = cell.display_lines(80); let rendered = render_lines(&lines).join("\n"); @@ -1856,16 +1912,7 @@ mod tests { start_time: Some(Instant::now()), duration: None, }); - cell.complete_call( - "c1", - CommandOutput { - exit_code: 0, - stdout: String::new(), - stderr: String::new(), - formatted_output: String::new(), - }, - Duration::from_millis(1), - ); + cell.complete_call("c1", CommandOutput::default(), Duration::from_millis(1)); let lines = cell.display_lines(80); let rendered = render_lines(&lines).join("\n"); insta::assert_snapshot!(rendered); @@ -1885,16 +1932,7 @@ mod tests { duration: None, }); // Mark call complete so it renders as "Ran" - cell.complete_call( - &call_id, - CommandOutput { - exit_code: 0, - stdout: String::new(), - stderr: String::new(), - formatted_output: String::new(), - }, - Duration::from_millis(1), - ); + cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1)); // Small width to force wrapping on both lines let width: u16 = 28; @@ -1914,16 +1952,7 @@ mod tests { start_time: Some(Instant::now()), duration: None, }); - cell.complete_call( - &call_id, - CommandOutput { - exit_code: 0, - stdout: String::new(), - stderr: String::new(), - formatted_output: String::new(), - }, - Duration::from_millis(1), - ); + cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1)); // Wide enough that it fits inline let lines = cell.display_lines(80); let rendered = render_lines(&lines).join("\n"); @@ -1942,16 +1971,7 @@ mod tests { start_time: Some(Instant::now()), duration: None, }); - cell.complete_call( - &call_id, - CommandOutput { - exit_code: 0, - stdout: String::new(), - stderr: String::new(), - formatted_output: String::new(), - }, - Duration::from_millis(1), - ); + cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1)); let lines = cell.display_lines(24); let rendered = render_lines(&lines).join("\n"); insta::assert_snapshot!(rendered); @@ -1969,16 +1989,7 @@ mod tests { start_time: Some(Instant::now()), duration: None, }); - cell.complete_call( - &call_id, - CommandOutput { - exit_code: 0, - stdout: String::new(), - stderr: String::new(), - formatted_output: String::new(), - }, - Duration::from_millis(1), - ); + cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1)); let lines = cell.display_lines(80); let rendered = render_lines(&lines).join("\n"); insta::assert_snapshot!(rendered); @@ -1997,16 +2008,7 @@ mod tests { start_time: Some(Instant::now()), duration: None, }); - cell.complete_call( - &call_id, - CommandOutput { - exit_code: 0, - stdout: String::new(), - stderr: String::new(), - formatted_output: String::new(), - }, - Duration::from_millis(1), - ); + cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1)); let lines = cell.display_lines(28); let rendered = render_lines(&lines).join("\n"); insta::assert_snapshot!(rendered); @@ -2033,9 +2035,8 @@ mod tests { &call_id, CommandOutput { exit_code: 1, - stdout: String::new(), - stderr, formatted_output: String::new(), + aggregated_output: stderr, }, Duration::from_millis(1), ); @@ -2077,9 +2078,8 @@ mod tests { &call_id, CommandOutput { exit_code: 1, - stdout: String::new(), - stderr, formatted_output: String::new(), + aggregated_output: stderr, }, Duration::from_millis(5), ); diff --git a/codex-rs/tui/src/key_hint.rs b/codex-rs/tui/src/key_hint.rs index b79c2a2703..795d1545f8 100644 --- a/codex-rs/tui/src/key_hint.rs +++ b/codex-rs/tui/src/key_hint.rs @@ -6,6 +6,9 @@ use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Span; +#[cfg(target_os = "macos")] +const ALT_PREFIX: &str = "⌥ + "; +#[cfg(not(target_os = "macos"))] const ALT_PREFIX: &str = "alt + "; const CTRL_PREFIX: &str = "ctrl + "; const SHIFT_PREFIX: &str = "shift + "; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 681b4c6ca1..9030b7f665 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -148,6 +148,7 @@ pub async fn run_main( include_view_image_tool: None, show_raw_agent_reasoning: cli.oss.then_some(true), tools_web_search_request: cli.web_search.then_some(true), + experimental_sandbox_command_assessment: None, additional_writable_roots: additional_dirs, }; let raw_overrides = cli.config_overrides.raw_overrides.clone(); @@ -390,11 +391,14 @@ async fn run_ratatui_app( } } } else if cli.resume_last { + let provider_filter = vec![config.model_provider_id.clone()]; match RolloutRecorder::list_conversations( &config.codex_home, 1, None, INTERACTIVE_SESSION_SOURCES, + Some(provider_filter.as_slice()), + &config.model_provider_id, ) .await { @@ -406,7 +410,13 @@ async fn run_ratatui_app( Err(_) => resume_picker::ResumeSelection::StartFresh, } } else if cli.resume_picker { - match resume_picker::run_resume_picker(&mut tui, &config.codex_home).await? { + match resume_picker::run_resume_picker( + &mut tui, + &config.codex_home, + &config.model_provider_id, + ) + .await? + { resume_picker::ResumeSelection::Exit => { restore(); session_log::log_session_end(); diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 84c325b6cb..b02e02ef73 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -724,8 +724,7 @@ mod tests { "exec-1", CommandOutput { exit_code: 0, - stdout: "src\nREADME.md\n".into(), - stderr: String::new(), + aggregated_output: "src\nREADME.md\n".into(), formatted_output: "src\nREADME.md\n".into(), }, Duration::from_millis(420), diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index b735ff9374..3104b32735 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -49,6 +49,7 @@ struct PageLoadRequest { cursor: Option, request_token: usize, search_token: Option, + default_provider: String, } type PageLoader = Arc; @@ -64,19 +65,28 @@ enum BackgroundEvent { /// Interactive session picker that lists recorded rollout files with simple /// search and pagination. Shows the first user input as the preview, relative /// time (e.g., "5 seconds ago"), and the absolute path. -pub async fn run_resume_picker(tui: &mut Tui, codex_home: &Path) -> Result { +pub async fn run_resume_picker( + tui: &mut Tui, + codex_home: &Path, + default_provider: &str, +) -> Result { let alt = AltScreenGuard::enter(tui); let (bg_tx, bg_rx) = mpsc::unbounded_channel(); + let default_provider = default_provider.to_string(); + let loader_tx = bg_tx.clone(); let page_loader: PageLoader = Arc::new(move |request: PageLoadRequest| { let tx = loader_tx.clone(); tokio::spawn(async move { + let provider_filter = vec![request.default_provider.clone()]; let page = RolloutRecorder::list_conversations( &request.codex_home, PAGE_SIZE, request.cursor.as_ref(), INTERACTIVE_SESSION_SOURCES, + Some(provider_filter.as_slice()), + request.default_provider.as_str(), ) .await; let _ = tx.send(BackgroundEvent::PageLoaded { @@ -91,6 +101,7 @@ pub async fn run_resume_picker(tui: &mut Tui, codex_home: &Path) -> Result, + default_provider: String, } struct PaginationState { @@ -225,7 +237,12 @@ struct Row { } impl PickerState { - fn new(codex_home: PathBuf, requester: FrameRequester, page_loader: PageLoader) -> Self { + fn new( + codex_home: PathBuf, + requester: FrameRequester, + page_loader: PageLoader, + default_provider: String, + ) -> Self { Self { codex_home, requester, @@ -246,6 +263,7 @@ impl PickerState { next_search_token: 0, page_loader, view_rows: None, + default_provider, } } @@ -324,11 +342,14 @@ impl PickerState { } async fn load_initial_page(&mut self) -> Result<()> { + let provider_filter = vec![self.default_provider.clone()]; let page = RolloutRecorder::list_conversations( &self.codex_home, PAGE_SIZE, None, INTERACTIVE_SESSION_SOURCES, + Some(provider_filter.as_slice()), + self.default_provider.as_str(), ) .await?; self.reset_pagination(); @@ -552,6 +573,7 @@ impl PickerState { cursor: Some(cursor), request_token, search_token, + default_provider: self.default_provider.clone(), }); } @@ -1061,8 +1083,12 @@ mod tests { use ratatui::layout::Layout; let loader: PageLoader = Arc::new(|_| {}); - let mut state = - PickerState::new(PathBuf::from("/tmp"), FrameRequester::test_dummy(), loader); + let mut state = PickerState::new( + PathBuf::from("/tmp"), + FrameRequester::test_dummy(), + loader, + String::from("openai"), + ); let now = Utc::now(); let rows = vec![ @@ -1117,8 +1143,12 @@ mod tests { #[test] fn pageless_scrolling_deduplicates_and_keeps_order() { let loader: PageLoader = Arc::new(|_| {}); - let mut state = - PickerState::new(PathBuf::from("/tmp"), FrameRequester::test_dummy(), loader); + let mut state = PickerState::new( + PathBuf::from("/tmp"), + FrameRequester::test_dummy(), + loader, + String::from("openai"), + ); state.reset_pagination(); state.ingest_page(page( @@ -1179,8 +1209,12 @@ mod tests { request_sink.lock().unwrap().push(req); }); - let mut state = - PickerState::new(PathBuf::from("/tmp"), FrameRequester::test_dummy(), loader); + let mut state = PickerState::new( + PathBuf::from("/tmp"), + FrameRequester::test_dummy(), + loader, + String::from("openai"), + ); state.reset_pagination(); state.ingest_page(page( vec![ @@ -1204,8 +1238,12 @@ mod tests { #[test] fn page_navigation_uses_view_rows() { let loader: PageLoader = Arc::new(|_| {}); - let mut state = - PickerState::new(PathBuf::from("/tmp"), FrameRequester::test_dummy(), loader); + let mut state = PickerState::new( + PathBuf::from("/tmp"), + FrameRequester::test_dummy(), + loader, + String::from("openai"), + ); let mut items = Vec::new(); for idx in 0..20 { @@ -1248,8 +1286,12 @@ mod tests { #[test] fn up_at_bottom_does_not_scroll_when_visible() { let loader: PageLoader = Arc::new(|_| {}); - let mut state = - PickerState::new(PathBuf::from("/tmp"), FrameRequester::test_dummy(), loader); + let mut state = PickerState::new( + PathBuf::from("/tmp"), + FrameRequester::test_dummy(), + loader, + String::from("openai"), + ); let mut items = Vec::new(); for idx in 0..10 { @@ -1288,8 +1330,12 @@ mod tests { request_sink.lock().unwrap().push(req); }); - let mut state = - PickerState::new(PathBuf::from("/tmp"), FrameRequester::test_dummy(), loader); + let mut state = PickerState::new( + PathBuf::from("/tmp"), + FrameRequester::test_dummy(), + loader, + String::from("openai"), + ); state.reset_pagination(); state.ingest_page(page( vec![make_item( diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 22da09f358..bb3be33099 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -39,7 +39,7 @@ impl SlashCommand { SlashCommand::Init => "create an AGENTS.md file with instructions for Codex", SlashCommand::Compact => "summarize conversation to prevent hitting the context limit", SlashCommand::Review => "review my current changes and find issues", - SlashCommand::Undo => "restore the workspace to the last Codex snapshot", + SlashCommand::Undo => "ask Codex to undo a turn", SlashCommand::Quit => "exit Codex", SlashCommand::Diff => "show git diff (including untracked files)", SlashCommand::Mention => "mention a file", @@ -85,14 +85,5 @@ impl SlashCommand { /// Return all built-in commands in a Vec paired with their command string. pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> { - let show_beta_features = beta_features_enabled(); - - SlashCommand::iter() - .filter(|cmd| *cmd != SlashCommand::Undo || show_beta_features) - .map(|c| (c.command(), c)) - .collect() -} - -fn beta_features_enabled() -> bool { - std::env::var_os("BETA_FEATURE").is_some() + SlashCommand::iter().map(|c| (c.command(), c)).collect() } diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__mcp_tools_output_masks_sensitive_values.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__mcp_tools_output_masks_sensitive_values.snap new file mode 100644 index 0000000000..b9c0dfdee5 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__mcp_tools_output_masks_sensitive_values.snap @@ -0,0 +1,27 @@ +--- +source: tui/src/history_cell.rs +assertion_line: 1540 +expression: rendered +--- +/mcp + +🔌 MCP Tools + + • docs + • Status: enabled + • Auth: Unsupported + • Command: docs-server + • Env: TOKEN=*****, APP_TOKEN=***** + • Tools: list + • Resources: (none) + • Resource templates: (none) + + • http + • Status: enabled + • Auth: Unsupported + • URL: https://example.com/mcp + • HTTP headers: Authorization=***** + • Env HTTP headers: X-API-Key=API_KEY_ENV + • Tools: ping + • Resources: (none) + • Resource templates: (none) diff --git a/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_with_queued_messages@macos.snap b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_with_queued_messages@macos.snap new file mode 100644 index 0000000000..5974455c89 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_with_queued_messages@macos.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/status_indicator_widget.rs +assertion_line: 289 +expression: terminal.backend() +--- +"• Working (0s • esc to interrupt) " +" " +" ↳ first " +" ↳ second " +" ⌥ + ↑ edit " +" " +" " +" " diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index a077a92661..9333509700 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -3,6 +3,8 @@ use crate::history_cell::HistoryCell; use crate::history_cell::PlainHistoryCell; use crate::history_cell::with_border_with_inner_width; use crate::version::CODEX_CLI_VERSION; +use chrono::DateTime; +use chrono::Local; use codex_common::create_config_summary_entries; use codex_core::config::Config; use codex_core::protocol::SandboxPolicy; @@ -25,6 +27,7 @@ use super::helpers::format_directory_display; use super::helpers::format_tokens_compact; use super::rate_limits::RateLimitSnapshotDisplay; use super::rate_limits::StatusRateLimitData; +use super::rate_limits::StatusRateLimitRow; use super::rate_limits::compose_rate_limit_data; use super::rate_limits::format_status_limit_summary; use super::rate_limits::render_status_limit_progress_bar; @@ -64,9 +67,17 @@ pub(crate) fn new_status_output( context_usage: Option<&TokenUsage>, session_id: &Option, rate_limits: Option<&RateLimitSnapshotDisplay>, + now: DateTime, ) -> CompositeHistoryCell { let command = PlainHistoryCell::new(vec!["/status".magenta().into()]); - let card = StatusHistoryCell::new(config, total_usage, context_usage, session_id, rate_limits); + let card = StatusHistoryCell::new( + config, + total_usage, + context_usage, + session_id, + rate_limits, + now, + ); CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)]) } @@ -78,6 +89,7 @@ impl StatusHistoryCell { context_usage: Option<&TokenUsage>, session_id: &Option, rate_limits: Option<&RateLimitSnapshotDisplay>, + now: DateTime, ) -> Self { let config_entries = create_config_summary_entries(config); let (model_name, model_details) = compose_model_display(config, &config_entries); @@ -108,7 +120,7 @@ impl StatusHistoryCell { output: total_usage.output_tokens, context_window, }; - let rate_limits = compose_rate_limit_data(rate_limits); + let rate_limits = compose_rate_limit_data(rate_limits, now); Self { model_name, @@ -171,47 +183,66 @@ impl StatusHistoryCell { ]; } - let mut lines = Vec::with_capacity(rows_data.len() * 2); - - for row in rows_data { - let value_spans = vec![ - Span::from(render_status_limit_progress_bar(row.percent_used)), - Span::from(" "), - Span::from(format_status_limit_summary(row.percent_used)), - ]; - let base_spans = formatter.full_spans(row.label.as_str(), value_spans); - let base_line = Line::from(base_spans.clone()); - - if let Some(resets_at) = row.resets_at.as_ref() { - let resets_span = Span::from(format!("(resets {resets_at})")).dim(); - let mut inline_spans = base_spans.clone(); - inline_spans.push(Span::from(" ").dim()); - inline_spans.push(resets_span.clone()); - - if line_display_width(&Line::from(inline_spans.clone())) - <= available_inner_width - { - lines.push(Line::from(inline_spans)); - } else { - lines.push(base_line); - lines.push(formatter.continuation(vec![resets_span])); - } - } else { - lines.push(base_line); - } - } - + self.rate_limit_row_lines(rows_data, available_inner_width, formatter) + } + StatusRateLimitData::Stale(rows_data) => { + let mut lines = + self.rate_limit_row_lines(rows_data, available_inner_width, formatter); + lines.push(formatter.line( + "Warning", + vec![Span::from("limits may be stale - start new turn to refresh.").dim()], + )); lines } StatusRateLimitData::Missing => { vec![formatter.line( "Limits", - vec![Span::from("send a message to load usage data").dim()], + vec![ + Span::from("visit ").dim(), + "chatgpt.com/codex/settings/usage".cyan().underlined(), + ], )] } } } + fn rate_limit_row_lines( + &self, + rows: &[StatusRateLimitRow], + available_inner_width: usize, + formatter: &FieldFormatter, + ) -> Vec> { + let mut lines = Vec::with_capacity(rows.len().saturating_mul(2)); + + for row in rows { + let value_spans = vec![ + Span::from(render_status_limit_progress_bar(row.percent_used)), + Span::from(" "), + Span::from(format_status_limit_summary(row.percent_used)), + ]; + let base_spans = formatter.full_spans(row.label.as_str(), value_spans); + let base_line = Line::from(base_spans.clone()); + + if let Some(resets_at) = row.resets_at.as_ref() { + let resets_span = Span::from(format!("(resets {resets_at})")).dim(); + let mut inline_spans = base_spans.clone(); + inline_spans.push(Span::from(" ").dim()); + inline_spans.push(resets_span.clone()); + + if line_display_width(&Line::from(inline_spans.clone())) <= available_inner_width { + lines.push(Line::from(inline_spans)); + } else { + lines.push(base_line); + lines.push(formatter.continuation(vec![resets_span])); + } + } else { + lines.push(base_line); + } + } + + lines + } + fn collect_rate_limit_labels(&self, seen: &mut BTreeSet, labels: &mut Vec) { match &self.rate_limits { StatusRateLimitData::Available(rows) => { @@ -223,6 +254,12 @@ impl StatusHistoryCell { } } } + StatusRateLimitData::Stale(rows) => { + for row in rows { + push_label(labels, seen, row.label.as_str()); + } + push_label(labels, seen, "Warning"); + } StatusRateLimitData::Missing => push_label(labels, seen, "Limits"), } } diff --git a/codex-rs/tui/src/status/rate_limits.rs b/codex-rs/tui/src/status/rate_limits.rs index ea63f189f3..4c08cdd673 100644 --- a/codex-rs/tui/src/status/rate_limits.rs +++ b/codex-rs/tui/src/status/rate_limits.rs @@ -2,6 +2,7 @@ use crate::chatwidget::get_limits_duration; use super::helpers::format_reset_timestamp; use chrono::DateTime; +use chrono::Duration as ChronoDuration; use chrono::Local; use chrono::Utc; use codex_core::protocol::RateLimitSnapshot; @@ -21,9 +22,12 @@ pub(crate) struct StatusRateLimitRow { #[derive(Debug, Clone)] pub(crate) enum StatusRateLimitData { Available(Vec), + Stale(Vec), Missing, } +pub(crate) const RATE_LIMIT_STALE_THRESHOLD_MINUTES: i64 = 15; + #[derive(Debug, Clone)] pub(crate) struct RateLimitWindowDisplay { pub used_percent: f64, @@ -49,6 +53,7 @@ impl RateLimitWindowDisplay { #[derive(Debug, Clone)] pub(crate) struct RateLimitSnapshotDisplay { + pub captured_at: DateTime, pub primary: Option, pub secondary: Option, } @@ -58,6 +63,7 @@ pub(crate) fn rate_limit_snapshot_display( captured_at: DateTime, ) -> RateLimitSnapshotDisplay { RateLimitSnapshotDisplay { + captured_at, primary: snapshot .primary .as_ref() @@ -71,6 +77,7 @@ pub(crate) fn rate_limit_snapshot_display( pub(crate) fn compose_rate_limit_data( snapshot: Option<&RateLimitSnapshotDisplay>, + now: DateTime, ) -> StatusRateLimitData { match snapshot { Some(snapshot) => { @@ -102,8 +109,13 @@ pub(crate) fn compose_rate_limit_data( }); } + let is_stale = now.signed_duration_since(snapshot.captured_at) + > ChronoDuration::minutes(RATE_LIMIT_STALE_THRESHOLD_MINUTES); + if rows.is_empty() { StatusRateLimitData::Available(vec![]) + } else if is_stale { + StatusRateLimitData::Stale(rows) } else { StatusRateLimitData::Available(rows) } diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap index 66bcfa3e21..9997006357 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap @@ -15,5 +15,5 @@ expression: sanitized │ │ │ Token usage: 750 total (500 input + 250 output) │ │ Context window: 100% left (750 used / 272K) │ -│ Limits: send a message to load usage data │ +│ Limits: visit chatgpt.com/codex/settings/usage │ ╰─────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap new file mode 100644 index 0000000000..bd07335210 --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭─────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Model: gpt-5-codex (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Approval: on-request │ +│ Sandbox: read-only │ +│ Agents.md: │ +│ │ +│ Token usage: 1.9K total (1K input + 900 output) │ +│ Context window: 100% left (2.1K used / 272K) │ +│ 5h limit: [███████████████░░░░░] 72% used (resets 03:14) │ +│ Weekly limit: [████████░░░░░░░░░░░░] 40% used (resets 03:34) │ +│ Warning: limits may be stale - start new turn to refresh. │ +╰─────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index b8c945eeec..4ab4a8ea63 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -111,7 +111,14 @@ fn status_snapshot_includes_reasoning_details() { }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); - let composite = new_status_output(&config, &usage, Some(&usage), &None, Some(&rate_display)); + let composite = new_status_output( + &config, + &usage, + Some(&usage), + &None, + Some(&rate_display), + captured_at, + ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { for line in &mut rendered_lines { @@ -152,7 +159,14 @@ fn status_snapshot_includes_monthly_limit() { }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); - let composite = new_status_output(&config, &usage, Some(&usage), &None, Some(&rate_display)); + let composite = new_status_output( + &config, + &usage, + Some(&usage), + &None, + Some(&rate_display), + captured_at, + ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { for line in &mut rendered_lines { @@ -178,7 +192,12 @@ fn status_card_token_usage_excludes_cached_tokens() { total_tokens: 2_100, }; - let composite = new_status_output(&config, &usage, Some(&usage), &None, None); + let now = chrono::Local + .with_ymd_and_hms(2024, 1, 1, 0, 0, 0) + .single() + .expect("timestamp"); + + let composite = new_status_output(&config, &usage, Some(&usage), &None, None, now); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -219,7 +238,14 @@ fn status_snapshot_truncates_in_narrow_terminal() { }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); - let composite = new_status_output(&config, &usage, Some(&usage), &None, Some(&rate_display)); + let composite = new_status_output( + &config, + &usage, + Some(&usage), + &None, + Some(&rate_display), + captured_at, + ); let mut rendered_lines = render_lines(&composite.display_lines(46)); if cfg!(windows) { for line in &mut rendered_lines { @@ -246,7 +272,12 @@ fn status_snapshot_shows_missing_limits_message() { total_tokens: 750, }; - let composite = new_status_output(&config, &usage, Some(&usage), &None, None); + let now = chrono::Local + .with_ymd_and_hms(2024, 2, 3, 4, 5, 6) + .single() + .expect("timestamp"); + + let composite = new_status_output(&config, &usage, Some(&usage), &None, None, now); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { for line in &mut rendered_lines { @@ -282,7 +313,66 @@ fn status_snapshot_shows_empty_limits_message() { .expect("timestamp"); let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); - let composite = new_status_output(&config, &usage, Some(&usage), &None, Some(&rate_display)); + let composite = new_status_output( + &config, + &usage, + Some(&usage), + &None, + Some(&rate_display), + captured_at, + ); + let mut rendered_lines = render_lines(&composite.display_lines(80)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!(sanitized); +} + +#[test] +fn status_snapshot_shows_stale_limits_message() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home); + config.model = "gpt-5-codex".to_string(); + config.cwd = PathBuf::from("/workspace/tests"); + + let usage = TokenUsage { + input_tokens: 1_200, + cached_input_tokens: 200, + output_tokens: 900, + reasoning_output_tokens: 150, + total_tokens: 2_250, + }; + + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 1, 2, 3, 4, 5) + .single() + .expect("timestamp"); + let snapshot = RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: 72.5, + window_minutes: Some(300), + resets_at: Some(reset_at_from(&captured_at, 600)), + }), + secondary: Some(RateLimitWindow { + used_percent: 40.0, + window_minutes: Some(10_080), + resets_at: Some(reset_at_from(&captured_at, 1_800)), + }), + }; + let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let now = captured_at + ChronoDuration::minutes(20); + + let composite = new_status_output( + &config, + &usage, + Some(&usage), + &None, + Some(&rate_display), + now, + ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { for line in &mut rendered_lines { @@ -314,7 +404,12 @@ fn status_context_window_uses_last_usage() { total_tokens: 13_679, }; - let composite = new_status_output(&config, &total_usage, Some(&last_usage), &None, None); + let now = chrono::Local + .with_ymd_and_hms(2024, 6, 1, 12, 0, 0) + .single() + .expect("timestamp"); + + let composite = new_status_output(&config, &total_usage, Some(&last_usage), &None, None, now); let rendered_lines = render_lines(&composite.display_lines(80)); let context_line = rendered_lines .into_iter() diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index 4bcc5cf267..cda52ffee7 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -25,6 +25,8 @@ pub(crate) struct StatusIndicatorWidget { header: String, /// Queued user messages to display under the status line. queued_messages: Vec, + /// Whether to show the interrupt hint (Esc). + show_interrupt_hint: bool, elapsed_running: Duration, last_resume_at: Instant, @@ -55,6 +57,7 @@ impl StatusIndicatorWidget { Self { header: String::from("Working"), queued_messages: Vec::new(), + show_interrupt_hint: true, elapsed_running: Duration::ZERO, last_resume_at: Instant::now(), is_paused: false, @@ -98,9 +101,11 @@ impl StatusIndicatorWidget { /// Update the animated header label (left of the brackets). pub(crate) fn update_header(&mut self, header: String) { - if self.header != header { - self.header = header; - } + self.header = header; + } + + pub(crate) fn set_interrupt_hint_visible(&mut self, visible: bool) { + self.show_interrupt_hint = visible; } #[cfg(test)] @@ -108,6 +113,11 @@ impl StatusIndicatorWidget { &self.header } + #[cfg(test)] + pub(crate) fn interrupt_hint_visible(&self) -> bool { + self.show_interrupt_hint + } + /// Replace the queued messages displayed beneath the header. pub(crate) fn set_queued_messages(&mut self, queued: Vec) { self.queued_messages = queued; @@ -175,12 +185,16 @@ impl WidgetRef for StatusIndicatorWidget { spans.push(spinner(Some(self.last_resume_at))); spans.push(" ".into()); spans.extend(shimmer_spans(&self.header)); - spans.extend(vec![ - " ".into(), - format!("({pretty_elapsed} • ").dim(), - key_hint::plain(KeyCode::Esc).into(), - " to interrupt)".dim(), - ]); + spans.push(" ".into()); + if self.show_interrupt_hint { + spans.extend(vec![ + format!("({pretty_elapsed} • ").dim(), + key_hint::plain(KeyCode::Esc).into(), + " to interrupt)".dim(), + ]); + } else { + spans.push(format!("({pretty_elapsed})").dim()); + } // Build lines: status, then queued messages, then spacer. let mut lines: Vec> = Vec::new(); @@ -284,6 +298,11 @@ mod tests { terminal .draw(|f| w.render_ref(f.area(), f.buffer_mut())) .expect("draw"); + #[cfg(target_os = "macos")] + insta::with_settings!({ snapshot_suffix => "macos" }, { + insta::assert_snapshot!(terminal.backend()); + }); + #[cfg(not(target_os = "macos"))] insta::assert_snapshot!(terminal.backend()); } diff --git a/codex-rs/utils/readiness/src/lib.rs b/codex-rs/utils/readiness/src/lib.rs index ac3a323fae..8b92f9387a 100644 --- a/codex-rs/utils/readiness/src/lib.rs +++ b/codex-rs/utils/readiness/src/lib.rs @@ -1,6 +1,7 @@ //! Readiness flag with token-based authorization and async waiting (Tokio). use std::collections::HashSet; +use std::fmt; use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicI32; use std::sync::atomic::Ordering; @@ -71,6 +72,10 @@ impl ReadinessFlag { .map_err(|_| errors::ReadinessError::TokenLockFailed)?; Ok(f(&mut guard)) } + + fn load_ready(&self) -> bool { + self.ready.load(Ordering::Acquire) + } } impl Default for ReadinessFlag { @@ -79,14 +84,37 @@ impl Default for ReadinessFlag { } } +impl fmt::Debug for ReadinessFlag { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ReadinessFlag") + .field("ready", &self.load_ready()) + .finish() + } +} + #[async_trait::async_trait] impl Readiness for ReadinessFlag { fn is_ready(&self) -> bool { - self.ready.load(Ordering::Acquire) + if self.load_ready() { + return true; + } + + if let Ok(tokens) = self.tokens.try_lock() + && tokens.is_empty() + { + let was_ready = self.ready.swap(true, Ordering::AcqRel); + drop(tokens); + if !was_ready { + let _ = self.tx.send(true); + } + return true; + } + + self.load_ready() } async fn subscribe(&self) -> Result { - if self.is_ready() { + if self.load_ready() { return Err(errors::ReadinessError::FlagAlreadyReady); } @@ -97,7 +125,7 @@ impl Readiness for ReadinessFlag { // check above and inserting the token. let inserted = self .with_tokens(|tokens| { - if self.is_ready() { + if self.load_ready() { return false; } tokens.insert(token); @@ -113,7 +141,7 @@ impl Readiness for ReadinessFlag { } async fn mark_ready(&self, token: Token) -> Result { - if self.is_ready() { + if self.load_ready() { return Ok(false); } if token.0 == 0 { @@ -202,7 +230,8 @@ mod tests { async fn mark_ready_rejects_unknown_token() -> Result<(), ReadinessError> { let flag = ReadinessFlag::new(); assert!(!flag.mark_ready(Token(42)).await?); - assert!(!flag.is_ready()); + assert!(!flag.load_ready()); + assert!(flag.is_ready()); Ok(()) } @@ -233,6 +262,19 @@ mod tests { Ok(()) } + #[tokio::test] + async fn is_ready_without_subscribers_marks_flag_ready() -> Result<(), ReadinessError> { + let flag = ReadinessFlag::new(); + + assert!(flag.is_ready()); + assert!(flag.is_ready()); + assert_matches!( + flag.subscribe().await, + Err(ReadinessError::FlagAlreadyReady) + ); + Ok(()) + } + #[tokio::test] async fn subscribe_returns_error_when_lock_is_held() { let flag = ReadinessFlag::new(); diff --git a/codex-rs/utils/tokenizer/src/lib.rs b/codex-rs/utils/tokenizer/src/lib.rs index 93740889d1..6cda6e635f 100644 --- a/codex-rs/utils/tokenizer/src/lib.rs +++ b/codex-rs/utils/tokenizer/src/lib.rs @@ -55,8 +55,13 @@ impl Tokenizer { Ok(Self { inner }) } + /// Default to `O200kBase` + pub fn try_default() -> Result { + Self::new(EncodingKind::O200kBase) + } + /// Build a tokenizer using an `OpenAI` model name (maps to an encoding). - /// Falls back to the `o200k_base` encoding when the model is unknown. + /// Falls back to the `O200kBase` encoding when the model is unknown. pub fn for_model(model: &str) -> Result { match tiktoken_rs::get_bpe_from_model(model) { Ok(inner) => Ok(Self { inner }), diff --git a/docs/config.md b/docs/config.md index 4186c4ff34..9223f48d26 100644 --- a/docs/config.md +++ b/docs/config.md @@ -417,7 +417,7 @@ cwd = "/Users//code/my-server" [mcp_servers.figma] url = "https://mcp.linear.app/mcp" # Optional environment variable containing a bearer token to use for auth -bearer_token_env_var = "" +bearer_token_env_var = "ENV_VAR" # Optional map of headers with hard-coded values. http_headers = { "HEADER_NAME" = "HEADER_VALUE" } # Optional map of headers whose values will be replaced with the environment variable. diff --git a/docs/faq.md b/docs/faq.md index 55a6e5e33b..6ad0d51efc 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -42,3 +42,14 @@ Running Codex directly on Windows may work, but is not officially supported. We ### Where should I start after installation? Follow the quick setup in [Install & build](./install.md) and then jump into [Getting started](./getting-started.md) for interactive usage tips, prompt examples, and AGENTS.md guidance. + +### `brew upgrade codex` isn't upgrading me + +If you're running Codex v0.46.0 or older, `brew upgrade codex` will not move you to the latest version because we migrated from a Homebrew formula to a cask. To upgrade, uninstall the existing oudated formula and then install the new cask: + +```bash +brew uninstall --formula codex +brew install --cask codex +``` + +After reinstalling, `brew upgrade --cask codex` will keep future releases up to date. diff --git a/sdk/typescript/tests/run.test.ts b/sdk/typescript/tests/run.test.ts index f495b1e6ec..a8a9c7fbe8 100644 --- a/sdk/typescript/tests/run.test.ts +++ b/sdk/typescript/tests/run.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { codexExecSpy } from "./codexExecSpy"; -import { describe, expect, it } from "@jest/globals"; +import { describe, expect, it, xit } from "@jest/globals"; import { Codex } from "../src/codex"; @@ -308,7 +308,8 @@ describe("Codex", () => { await close(); } }); - it("forwards images to exec", async () => { + // TODO(pakrym): unskip the test + xit("forwards images to exec", async () => { const { url, close } = await startResponsesTestProxy({ statusCode: 200, responseBodies: [