From a30a902db56a134239d2e0c9c21ec8f91649f7b6 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sun, 5 Oct 2025 13:58:30 -0700 Subject: [PATCH 01/21] fix: use low-level stdin read logic to avoid a BufReader (#4778) `codex-responses-api-proxy` is designed so that there should be exactly one copy of the API key in memory (that is `mlock`'d on UNIX), but in practice, I was seeing two when I dumped the process data from `/proc/$PID/mem`. It appears that `std::io::stdin()` maintains an internal `BufReader` that we cannot zero out, so this PR changes the implementation on UNIX so that we use a low-level `read(2)` instead. Even though it seems like it would be incredibly unlikely, we also make this logic tolerant of short reads. Either `\n` or `EOF` must be sent to signal the end of the key written to stdin. --- codex-rs/responses-api-proxy/README.md | 8 +- .../responses-api-proxy/src/read_api_key.rs | 138 ++++++++++++++++-- 2 files changed, 133 insertions(+), 13 deletions(-) diff --git a/codex-rs/responses-api-proxy/README.md b/codex-rs/responses-api-proxy/README.md index 4b74fe10d6..31ac9c45c4 100644 --- a/codex-rs/responses-api-proxy/README.md +++ b/codex-rs/responses-api-proxy/README.md @@ -4,12 +4,12 @@ A strict HTTP proxy that only forwards `POST` requests to `/v1/responses` to the ## Expected Usage -**IMPORTANT:** `codex-responses-api-proxy` is designed to be run by a privileged user with access to `OPENAI_API_KEY` so that an unprivileged user cannot inspect or tamper with the process. Though if `--http-shutdown` is specified, an unprivileged user _can_ make a `GET` request to `/shutdown` to shutdown the server, as an unprivileged could not send `SIGTERM` to kill the process. +**IMPORTANT:** `codex-responses-api-proxy` is designed to be run by a privileged user with access to `OPENAI_API_KEY` so that an unprivileged user cannot inspect or tamper with the process. Though if `--http-shutdown` is specified, an unprivileged user _can_ make a `GET` request to `/shutdown` to shutdown the server, as an unprivileged user could not send `SIGTERM` to kill the process. A privileged user (i.e., `root` or a user with `sudo`) who has access to `OPENAI_API_KEY` would run the following to start the server, as `codex-responses-api-proxy` reads the auth token from `stdin`: ```shell -printenv OPENAI_API_KEY | codex-responses-api-proxy --http-shutdown --server-info /tmp/server-info.json +printenv OPENAI_API_KEY | env -u OPENAI_API_KEY codex-responses-api-proxy --http-shutdown --server-info /tmp/server-info.json ``` A non-privileged user would then run Codex as follows, specifying the `model_provider` dynamically: @@ -35,7 +35,7 @@ curl --fail --silent --show-error "${PROXY_BASE_URL}/shutdown" - Listens on the provided port or an ephemeral port if `--port` is not specified. - Accepts exactly `POST /v1/responses` (no query string). The request body is forwarded to `https://api.openai.com/v1/responses` with `Authorization: Bearer ` set. All original request headers (except any incoming `Authorization`) are forwarded upstream. For other requests, it responds with `403`. - Optionally writes a single-line JSON file with server info, currently `{ "port": }`. -- Optional `--http-shutdown` enables `GET /shutdown` to terminate the process with exit code 0. This allows one user (e.g., `root`) to start the proxy and another unprivileged user on the host to shut it down. +- Optional `--http-shutdown` enables `GET /shutdown` to terminate the process with exit code `0`. This allows one user (e.g., `root`) to start the proxy and another unprivileged user on the host to shut it down. ## CLI @@ -44,7 +44,7 @@ codex-responses-api-proxy [--port ] [--server-info ] [--http-shutdow ``` - `--port `: Port to bind on `127.0.0.1`. If omitted, an ephemeral port is chosen. -- `--server-info `: If set, the proxy writes a single line of JSON with `{ "port": }` once listening. +- `--server-info `: If set, the proxy writes a single line of JSON with `{ "port": , "pid": }` once listening. - `--http-shutdown`: If set, enables `GET /shutdown` to exit the process with code `0`. ## Notes diff --git a/codex-rs/responses-api-proxy/src/read_api_key.rs b/codex-rs/responses-api-proxy/src/read_api_key.rs index 1dda92f380..f3950b540b 100644 --- a/codex-rs/responses-api-proxy/src/read_api_key.rs +++ b/codex-rs/responses-api-proxy/src/read_api_key.rs @@ -1,7 +1,6 @@ use anyhow::Context; use anyhow::Result; use anyhow::anyhow; -use std::io::Read; use zeroize::Zeroize; /// Use a generous buffer size to avoid truncation and to allow for longer API @@ -13,13 +12,66 @@ const AUTH_HEADER_PREFIX: &[u8] = b"Bearer "; /// value with the auth token used with `Bearer`. The header value is returned /// as a `&'static str` whose bytes are locked in memory to avoid accidental /// exposure. +#[cfg(unix)] pub(crate) fn read_auth_header_from_stdin() -> Result<&'static str> { + read_auth_header_with(read_from_unix_stdin) +} + +#[cfg(windows)] +pub(crate) fn read_auth_header_from_stdin() -> Result<&'static str> { + use std::io::Read; + + // Use of `stdio::io::stdin()` has the problem mentioned in the docstring on + // the UNIX version of `read_from_unix_stdin()`, so this should ultimately + // be replaced the low-level Windows equivalent. Because we do not have an + // equivalent of mlock() on Windows right now, it is not pressing until we + // address that issue. read_auth_header_with(|buffer| std::io::stdin().read(buffer)) } -fn read_auth_header_with(read_fn: F) -> Result<&'static str> +/// We perform a low-level read with `read(2)` because `stdio::io::stdin()` has +/// an internal BufReader: +/// +/// https://github.com/rust-lang/rust/blob/bcbbdcb8522fd3cb4a8dde62313b251ab107694d/library/std/src/io/stdio.rs#L250-L252 +/// +/// that can end up retaining a copy of stdin data in memory with no way to zero +/// it out, whereas we aim to guarantee there is exactly one copy of the API key +/// in memory, protected by mlock(2). +#[cfg(unix)] +fn read_from_unix_stdin(buffer: &mut [u8]) -> std::io::Result { + use libc::c_void; + use libc::read; + + // Perform a single read(2) call into the provided buffer slice. + // Looping and newline/EOF handling are managed by the caller. + loop { + let result = unsafe { + read( + libc::STDIN_FILENO, + buffer.as_mut_ptr().cast::(), + buffer.len(), + ) + }; + + if result == 0 { + return Ok(0); + } + + if result < 0 { + let err = std::io::Error::last_os_error(); + if err.kind() == std::io::ErrorKind::Interrupted { + continue; + } + return Err(err); + } + + return Ok(result as usize); + } +} + +fn read_auth_header_with(mut read_fn: F) -> Result<&'static str> where - F: FnOnce(&mut [u8]) -> std::io::Result, + F: FnMut(&mut [u8]) -> std::io::Result, { // TAKE CARE WHEN MODIFYING THIS CODE!!! // @@ -31,19 +83,50 @@ where let mut buf = [0u8; BUFFER_SIZE]; buf[..AUTH_HEADER_PREFIX.len()].copy_from_slice(AUTH_HEADER_PREFIX); - let read = read_fn(&mut buf[AUTH_HEADER_PREFIX.len()..]).inspect_err(|_err| { - buf.zeroize(); - })?; + let prefix_len = AUTH_HEADER_PREFIX.len(); + let capacity = buf.len() - prefix_len; + let mut total_read = 0usize; // number of bytes read into the token region + let mut saw_newline = false; + let mut saw_eof = false; - if read == buf.len() - AUTH_HEADER_PREFIX.len() { + while total_read < capacity { + let slice = &mut buf[prefix_len + total_read..]; + let read = match read_fn(slice) { + Ok(n) => n, + Err(err) => { + buf.zeroize(); + return Err(err.into()); + } + }; + + if read == 0 { + saw_eof = true; + break; + } + + // Search only the newly written region for a newline. + let newly_written = &slice[..read]; + if let Some(pos) = newly_written.iter().position(|&b| b == b'\n') { + total_read += pos + 1; // include the newline for trimming below + saw_newline = true; + break; + } + + total_read += read; + + // Continue loop; if buffer fills without newline/EOF we'll error below. + } + + // If buffer filled and we did not see newline or EOF, error out. + if total_read == capacity && !saw_newline && !saw_eof { buf.zeroize(); return Err(anyhow!( "OPENAI_API_KEY is too large to fit in the 512-byte buffer" )); } - let mut total = AUTH_HEADER_PREFIX.len() + read; - while total > AUTH_HEADER_PREFIX.len() && (buf[total - 1] == b'\n' || buf[total - 1] == b'\r') { + let mut total = prefix_len + total_read; + while total > prefix_len && (buf[total - 1] == b'\n' || buf[total - 1] == b'\r') { total -= 1; } @@ -138,13 +221,19 @@ fn validate_auth_header_bytes(key_bytes: &[u8]) -> Result<()> { #[cfg(test)] mod tests { use super::*; + use std::collections::VecDeque; use std::io; #[test] fn reads_key_with_no_newlines() { + let mut sent = false; let result = read_auth_header_with(|buf| { + if sent { + return Ok(0); + } let data = b"sk-abc123"; buf[..data.len()].copy_from_slice(data); + sent = true; Ok(data.len()) }) .unwrap(); @@ -152,11 +241,32 @@ mod tests { assert_eq!(result, "Bearer sk-abc123"); } + #[test] + fn reads_key_with_short_reads() { + let mut chunks: VecDeque<&[u8]> = + VecDeque::from(vec![b"sk-".as_ref(), b"abc".as_ref(), b"123\n".as_ref()]); + let result = read_auth_header_with(|buf| match chunks.pop_front() { + Some(chunk) if !chunk.is_empty() => { + buf[..chunk.len()].copy_from_slice(chunk); + Ok(chunk.len()) + } + _ => Ok(0), + }) + .unwrap(); + + assert_eq!(result, "Bearer sk-abc123"); + } + #[test] fn reads_key_and_trims_newlines() { + let mut sent = false; let result = read_auth_header_with(|buf| { + if sent { + return Ok(0); + } let data = b"sk-abc123\r\n"; buf[..data.len()].copy_from_slice(data); + sent = true; Ok(data.len()) }) .unwrap(); @@ -194,9 +304,14 @@ mod tests { #[test] fn errors_on_invalid_utf8() { + let mut sent = false; let err = read_auth_header_with(|buf| { + if sent { + return Ok(0); + } let data = b"sk-abc\xff"; buf[..data.len()].copy_from_slice(data); + sent = true; Ok(data.len()) }) .unwrap_err(); @@ -209,9 +324,14 @@ mod tests { #[test] fn errors_on_invalid_characters() { + let mut sent = false; let err = read_auth_header_with(|buf| { + if sent { + return Ok(0); + } let data = b"sk-abc!23"; buf[..data.len()].copy_from_slice(data); + sent = true; Ok(data.len()) }) .unwrap_err(); From aecbe0f333a1ad3a4e45be41d8585a66e42655a0 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Sun, 5 Oct 2025 14:11:43 -0700 Subject: [PATCH 02/21] Add helper for response created SSE events in tests (#4758) ## Summary - add a reusable `ev_response_created` helper that builds `response.created` SSE events for integration tests - update the exec and core integration suites to use the new helper instead of repeating manual JSON literals - keep the streaming fixtures consistent by relying on the shared helper in every touched test ## Testing - `just fmt` ------ https://chatgpt.com/codex/tasks/task_i_68e1fe885bb883208aafffb94218da61 --- codex-rs/core/tests/common/responses.rs | 10 +++++++ codex-rs/core/tests/suite/read_file.rs | 6 ++--- codex-rs/core/tests/suite/rmcp_client.rs | 15 +++-------- .../core/tests/suite/shell_serialization.rs | 7 ++--- codex-rs/core/tests/suite/tool_harness.rs | 26 +++++-------------- codex-rs/core/tests/suite/tools.rs | 17 ++++++------ codex-rs/core/tests/suite/unified_exec.rs | 9 ++++--- codex-rs/core/tests/suite/view_image.rs | 16 +++--------- codex-rs/exec/tests/suite/output_schema.rs | 5 +--- 9 files changed, 44 insertions(+), 67 deletions(-) diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index f3b4d6af11..24ea824ac6 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -34,6 +34,16 @@ pub fn ev_completed(id: &str) -> Value { }) } +/// Convenience: SSE event for a created response with a specific id. +pub fn ev_response_created(id: &str) -> Value { + serde_json::json!({ + "type": "response.created", + "response": { + "id": id, + } + }) +} + pub fn ev_completed_with_tokens(id: &str, total_tokens: u64) -> Value { serde_json::json!({ "type": "response.completed", diff --git a/codex-rs/core/tests/suite/read_file.rs b/codex-rs/core/tests/suite/read_file.rs index 5ec17c7bdf..a6c8a7a181 100644 --- a/codex-rs/core/tests/suite/read_file.rs +++ b/codex-rs/core/tests/suite/read_file.rs @@ -10,6 +10,7 @@ use core_test_support::responses; 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::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; @@ -47,10 +48,7 @@ async fn read_file_tool_returns_requested_lines() -> anyhow::Result<()> { .to_string(); let first_response = sse(vec![ - serde_json::json!({ - "type": "response.created", - "response": {"id": "resp-1"} - }), + ev_response_created("resp-1"), ev_function_call(call_id, "read_file", &arguments), ev_completed("resp-1"), ]); diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 413efe798d..212b03bd5c 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -47,10 +47,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { &server, any(), responses::sse(vec![ - serde_json::json!({ - "type": "response.created", - "response": {"id": "resp-1"} - }), + responses::ev_response_created("resp-1"), responses::ev_function_call(call_id, &tool_name, "{\"message\":\"ping\"}"), responses::ev_completed("resp-1"), ]), @@ -184,10 +181,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { &server, any(), responses::sse(vec![ - serde_json::json!({ - "type": "response.created", - "response": {"id": "resp-1"} - }), + responses::ev_response_created("resp-1"), responses::ev_function_call(call_id, &tool_name, "{\"message\":\"ping\"}"), responses::ev_completed("resp-1"), ]), @@ -352,10 +346,7 @@ async fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> { &server, any(), responses::sse(vec![ - serde_json::json!({ - "type": "response.created", - "response": {"id": "resp-1"} - }), + responses::ev_response_created("resp-1"), responses::ev_function_call(call_id, &tool_name, "{\"message\":\"ping\"}"), responses::ev_completed("resp-1"), ]), diff --git a/codex-rs/core/tests/suite/shell_serialization.rs b/codex-rs/core/tests/suite/shell_serialization.rs index 599a920323..8e4a4757e8 100644 --- a/codex-rs/core/tests/suite/shell_serialization.rs +++ b/codex-rs/core/tests/suite/shell_serialization.rs @@ -11,6 +11,7 @@ use codex_protocol::config_types::ReasoningSummary; 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::responses::start_mock_server; @@ -88,7 +89,7 @@ async fn shell_output_stays_json_without_freeform_apply_patch() -> Result<()> { }); let responses = vec![ sse(vec![ - json!({"type": "response.created", "response": {"id": "resp-1"}}), + ev_response_created("resp-1"), ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), @@ -155,7 +156,7 @@ async fn shell_output_is_structured_with_freeform_apply_patch() -> Result<()> { }); let responses = vec![ sse(vec![ - json!({"type": "response.created", "response": {"id": "resp-1"}}), + ev_response_created("resp-1"), ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), @@ -224,7 +225,7 @@ async fn shell_output_reserializes_truncated_content() -> Result<()> { }); let responses = vec![ sse(vec![ - json!({"type": "response.created", "response": {"id": "resp-1"}}), + ev_response_created("resp-1"), ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index b6feffc934..fc74c412eb 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -14,6 +14,7 @@ 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_local_shell_call; +use core_test_support::responses::ev_response_created; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; @@ -68,10 +69,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> let call_id = "shell-tool-call"; let command = vec!["/bin/echo", "tool harness"]; let first_response = sse(vec![ - serde_json::json!({ - "type": "response.created", - "response": {"id": "resp-1"} - }), + ev_response_created("resp-1"), ev_local_shell_call(call_id, "completed", command), ev_completed("resp-1"), ]); @@ -152,10 +150,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { .to_string(); let first_response = sse(vec![ - serde_json::json!({ - "type": "response.created", - "response": {"id": "resp-1"} - }), + ev_response_created("resp-1"), ev_function_call(call_id, "update_plan", &plan_args), ev_completed("resp-1"), ]); @@ -247,10 +242,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { .to_string(); let first_response = sse(vec![ - serde_json::json!({ - "type": "response.created", - "response": {"id": "resp-1"} - }), + ev_response_created("resp-1"), ev_function_call(call_id, "update_plan", &invalid_args), ev_completed("resp-1"), ]); @@ -353,10 +345,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() *** End Patch"#; let first_response = sse(vec![ - serde_json::json!({ - "type": "response.created", - "response": {"id": "resp-1"} - }), + ev_response_created("resp-1"), ev_apply_patch_function_call(call_id, patch_content), ev_completed("resp-1"), ]); @@ -482,10 +471,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { *** End Patch"; let first_response = sse(vec![ - serde_json::json!({ - "type": "response.created", - "response": {"id": "resp-1"} - }), + ev_response_created("resp-1"), ev_apply_patch_function_call(call_id, patch_content), ev_completed("resp-1"), ]); diff --git a/codex-rs/core/tests/suite/tools.rs b/codex-rs/core/tests/suite/tools.rs index 0dcd40c24c..a2cae76cab 100644 --- a/codex-rs/core/tests/suite/tools.rs +++ b/codex-rs/core/tests/suite/tools.rs @@ -13,6 +13,7 @@ use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_custom_tool_call; 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::responses::start_mock_server; @@ -106,7 +107,7 @@ async fn custom_tool_unknown_returns_custom_output_error() -> Result<()> { let responses = vec![ sse(vec![ - json!({"type": "response.created", "response": {"id": "resp-1"}}), + ev_response_created("resp-1"), ev_custom_tool_call(call_id, tool_name, "\"payload\""), ev_completed("resp-1"), ]), @@ -169,7 +170,7 @@ async fn shell_escalated_permissions_rejected_then_ok() -> Result<()> { let responses = vec![ sse(vec![ - json!({"type": "response.created", "response": {"id": "resp-1"}}), + ev_response_created("resp-1"), ev_function_call( call_id_blocked, "shell", @@ -178,7 +179,7 @@ async fn shell_escalated_permissions_rejected_then_ok() -> Result<()> { ev_completed("resp-1"), ]), sse(vec![ - json!({"type": "response.created", "response": {"id": "resp-2"}}), + ev_response_created("resp-2"), ev_function_call( call_id_success, "shell", @@ -283,7 +284,7 @@ async fn local_shell_missing_ids_maps_to_function_output_error() -> Result<()> { let responses = vec![ sse(vec![ - json!({"type": "response.created", "response": {"id": "resp-1"}}), + ev_response_created("resp-1"), local_shell_event, ev_completed("resp-1"), ]), @@ -324,7 +325,7 @@ async fn collect_tools(use_unified_exec: bool) -> Result> { let server = start_mock_server().await; let responses = vec![sse(vec![ - json!({"type": "response.created", "response": {"id": "resp-1"}}), + ev_response_created("resp-1"), ev_assistant_message("msg-1", "done"), ev_completed("resp-1"), ])]; @@ -393,7 +394,7 @@ async fn shell_timeout_includes_timeout_prefix_and_metadata() -> Result<()> { let responses = vec![ sse(vec![ - json!({"type": "response.created", "response": {"id": "resp-1"}}), + ev_response_created("resp-1"), ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), @@ -485,7 +486,7 @@ async fn shell_sandbox_denied_truncates_error_output() -> Result<()> { let responses = vec![ sse(vec![ - json!({"type": "response.created", "response": {"id": "resp-1"}}), + ev_response_created("resp-1"), ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), @@ -571,7 +572,7 @@ async fn shell_spawn_failure_truncates_exec_error() -> Result<()> { let responses = vec![ sse(vec![ - json!({"type": "response.created", "response": {"id": "resp-1"}}), + ev_response_created("resp-1"), ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 0676c25bb1..5afcc9f5fa 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -12,6 +12,7 @@ use codex_protocol::config_types::ReasoningSummary; 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::responses::start_mock_server; @@ -82,7 +83,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { let responses = vec![ sse(vec![ - serde_json::json!({"type": "response.created", "response": {"id": "resp-1"}}), + ev_response_created("resp-1"), ev_function_call( first_call_id, "unified_exec", @@ -91,7 +92,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { ev_completed("resp-1"), ]), sse(vec![ - serde_json::json!({"type": "response.created", "response": {"id": "resp-2"}}), + ev_response_created("resp-2"), ev_function_call( second_call_id, "unified_exec", @@ -198,7 +199,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { let responses = vec![ sse(vec![ - serde_json::json!({"type": "response.created", "response": {"id": "resp-1"}}), + ev_response_created("resp-1"), ev_function_call( first_call_id, "unified_exec", @@ -207,7 +208,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { ev_completed("resp-1"), ]), sse(vec![ - serde_json::json!({"type": "response.created", "response": {"id": "resp-2"}}), + ev_response_created("resp-2"), ev_function_call( second_call_id, "unified_exec", diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 4a953b9831..16913f4f93 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -12,6 +12,7 @@ use core_test_support::responses; 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::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; @@ -89,10 +90,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { let arguments = serde_json::json!({ "path": rel_path }).to_string(); let first_response = sse(vec![ - serde_json::json!({ - "type": "response.created", - "response": {"id": "resp-1"} - }), + ev_response_created("resp-1"), ev_function_call(call_id, "view_image", &arguments), ev_completed("resp-1"), ]); @@ -202,10 +200,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { let arguments = serde_json::json!({ "path": rel_path }).to_string(); let first_response = sse(vec![ - serde_json::json!({ - "type": "response.created", - "response": {"id": "resp-1"} - }), + ev_response_created("resp-1"), ev_function_call(call_id, "view_image", &arguments), ev_completed("resp-1"), ]); @@ -282,10 +277,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { let arguments = serde_json::json!({ "path": rel_path }).to_string(); let first_response = sse(vec![ - serde_json::json!({ - "type": "response.created", - "response": {"id": "resp-1"} - }), + ev_response_created("resp-1"), ev_function_call(call_id, "view_image", &arguments), ev_completed("resp-1"), ]); diff --git a/codex-rs/exec/tests/suite/output_schema.rs b/codex-rs/exec/tests/suite/output_schema.rs index d9fcb763e7..b054484f7c 100644 --- a/codex-rs/exec/tests/suite/output_schema.rs +++ b/codex-rs/exec/tests/suite/output_schema.rs @@ -24,10 +24,7 @@ async fn exec_includes_output_schema_in_request() -> anyhow::Result<()> { let server = responses::start_mock_server().await; let body = responses::sse(vec![ - serde_json::json!({ - "type": "response.created", - "response": {"id": "resp1"} - }), + responses::ev_response_created("resp1"), responses::ev_assistant_message("m1", "fixture hello"), responses::ev_completed("resp1"), ]); From 5c42419b02992ca56307b8795a1dfda390bced2b Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Sun, 5 Oct 2025 14:12:31 -0700 Subject: [PATCH 03/21] Use assert_matches (#4756) assert_matches is soon to be in std but is experimental for now. --- codex-rs/Cargo.lock | 13 ++++++++ codex-rs/Cargo.toml | 1 + codex-rs/apply-patch/Cargo.toml | 1 + codex-rs/apply-patch/src/lib.rs | 13 ++++---- codex-rs/cli/Cargo.toml | 1 + codex-rs/cli/src/main.rs | 9 +++--- codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/client.rs | 8 ++--- codex-rs/core/src/config.rs | 10 +++---- codex-rs/core/src/conversation_manager.rs | 3 +- codex-rs/core/src/event_mapping.rs | 3 +- codex-rs/core/tests/chat_completions_sse.rs | 11 +++---- codex-rs/core/tests/suite/tool_harness.rs | 5 ++-- codex-rs/git-tooling/Cargo.toml | 1 + codex-rs/git-tooling/src/ghost_commits.rs | 5 ++-- codex-rs/ollama/Cargo.toml | 1 + codex-rs/ollama/src/parser.rs | 33 ++++++++------------- codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/chatwidget/tests.rs | 13 +++----- codex-rs/utils/readiness/Cargo.toml | 1 + codex-rs/utils/readiness/src/lib.rs | 3 +- 21 files changed, 75 insertions(+), 62 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d579f29346..29025095bc 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -300,6 +300,12 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "assert_matches" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" + [[package]] name = "async-broadcast" version = "0.7.2" @@ -871,6 +877,7 @@ version = "0.0.0" dependencies = [ "anyhow", "assert_cmd", + "assert_matches", "pretty_assertions", "similar", "tempfile", @@ -933,6 +940,7 @@ version = "0.0.0" dependencies = [ "anyhow", "assert_cmd", + "assert_matches", "clap", "clap_complete", "codex-app-server", @@ -1022,6 +1030,7 @@ dependencies = [ "anyhow", "askama", "assert_cmd", + "assert_matches", "async-channel", "async-trait", "base64", @@ -1162,6 +1171,7 @@ dependencies = [ name = "codex-git-tooling" version = "0.0.0" dependencies = [ + "assert_matches", "pretty_assertions", "tempfile", "thiserror 2.0.16", @@ -1249,6 +1259,7 @@ dependencies = [ name = "codex-ollama" version = "0.0.0" dependencies = [ + "assert_matches", "async-stream", "bytes", "codex-core", @@ -1367,6 +1378,7 @@ version = "0.0.0" dependencies = [ "anyhow", "arboard", + "assert_matches", "async-stream", "base64", "chrono", @@ -1432,6 +1444,7 @@ dependencies = [ name = "codex-utils-readiness" version = "0.0.0" dependencies = [ + "assert_matches", "async-trait", "thiserror 2.0.16", "time", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index f9cd07119b..d796e96783 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -83,6 +83,7 @@ ansi-to-tui = "7.0.0" anyhow = "1" arboard = "3" askama = "0.12" +assert_matches = "1.5.0" assert_cmd = "2" async-channel = "2.3.1" async-stream = "0.3.6" diff --git a/codex-rs/apply-patch/Cargo.toml b/codex-rs/apply-patch/Cargo.toml index 9445ae08b7..a239cd6310 100644 --- a/codex-rs/apply-patch/Cargo.toml +++ b/codex-rs/apply-patch/Cargo.toml @@ -23,5 +23,6 @@ tree-sitter-bash = { workspace = true } [dev-dependencies] assert_cmd = { workspace = true } +assert_matches = { workspace = true } pretty_assertions = { workspace = true } tempfile = { workspace = true } diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs index 3737c6ea6d..3d0052ed03 100644 --- a/codex-rs/apply-patch/src/lib.rs +++ b/codex-rs/apply-patch/src/lib.rs @@ -843,6 +843,7 @@ pub fn print_summary( #[cfg(test)] mod tests { use super::*; + use assert_matches::assert_matches; use pretty_assertions::assert_eq; use std::fs; use std::string::ToString; @@ -894,10 +895,10 @@ mod tests { fn assert_not_match(script: &str) { let args = args_bash(script); - assert!(matches!( + assert_matches!( maybe_parse_apply_patch(&args), MaybeApplyPatch::NotApplyPatch - )); + ); } #[test] @@ -905,10 +906,10 @@ mod tests { let patch = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch".to_string(); let args = vec![patch]; let dir = tempdir().unwrap(); - assert!(matches!( + assert_matches!( maybe_parse_apply_patch_verified(&args, dir.path()), MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation) - )); + ); } #[test] @@ -916,10 +917,10 @@ mod tests { let script = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch"; let args = args_bash(script); let dir = tempdir().unwrap(); - assert!(matches!( + assert_matches!( maybe_parse_apply_patch_verified(&args, dir.path()), MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation) - )); + ); } #[test] diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index bcece87256..453ab80771 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -47,6 +47,7 @@ tokio = { workspace = true, features = [ ] } [dev-dependencies] +assert_matches = { workspace = true } assert_cmd = { workspace = true } predicates = { workspace = true } pretty_assertions = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 51c13742d9..79a187938a 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -472,6 +472,7 @@ fn print_completion(cmd: CompletionCommand) { #[cfg(test)] mod tests { use super::*; + use assert_matches::assert_matches; use codex_core::protocol::TokenUsage; use codex_protocol::ConversationId; @@ -604,14 +605,14 @@ mod tests { assert_eq!(interactive.model.as_deref(), Some("gpt-5-test")); assert!(interactive.oss); assert_eq!(interactive.config_profile.as_deref(), Some("my-profile")); - assert!(matches!( + assert_matches!( interactive.sandbox_mode, Some(codex_common::SandboxModeCliArg::WorkspaceWrite) - )); - assert!(matches!( + ); + assert_matches!( interactive.approval_policy, Some(codex_common::ApprovalModeCliArg::OnRequest) - )); + ); assert!(interactive.full_auto); assert_eq!( interactive.cwd.as_deref(), diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 3d499f9007..367ccbcef2 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -89,6 +89,7 @@ openssl-sys = { workspace = true, features = ["vendored"] } [dev-dependencies] assert_cmd = { workspace = true } +assert_matches = { workspace = true } core_test_support = { workspace = true } escargot = { workspace = true } maplit = { workspace = true } diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 0bd7848d1d..3c21552dcb 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -932,6 +932,7 @@ fn is_context_window_error(error: &Error) -> bool { #[cfg(test)] mod tests { use super::*; + use assert_matches::assert_matches; use serde_json::json; use tokio::sync::mpsc; use tokio_test::io::Builder as IoBuilder; @@ -1391,10 +1392,7 @@ mod tests { let resp: ErrorResponse = serde_json::from_str(json).expect("should deserialize old schema"); - assert!(matches!( - resp.error.plan_type, - Some(PlanType::Known(KnownPlan::Pro)) - )); + assert_matches!(resp.error.plan_type, Some(PlanType::Known(KnownPlan::Pro))); let plan_json = serde_json::to_string(&resp.error.plan_type).expect("serialize plan_type"); assert_eq!(plan_json, "\"pro\""); @@ -1409,7 +1407,7 @@ mod tests { let resp: ErrorResponse = serde_json::from_str(json).expect("should deserialize old schema"); - assert!(matches!(resp.error.plan_type, Some(PlanType::Unknown(ref s)) if s == "vip")); + assert_matches!(resp.error.plan_type, Some(PlanType::Unknown(ref s)) if s == "vip"); let plan_json = serde_json::to_string(&resp.error.plan_type).expect("serialize plan_type"); assert_eq!(plan_json, "\"vip\""); diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 6fb83cee96..28ad84ba7a 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -2231,6 +2231,7 @@ trust_level = "trusted" #[cfg(test)] mod notifications_tests { use crate::config_types::Notifications; + use assert_matches::assert_matches; use serde::Deserialize; #[derive(Deserialize, Debug, PartialEq)] @@ -2250,10 +2251,7 @@ mod notifications_tests { notifications = true "#; let parsed: RootTomlTest = toml::from_str(toml).expect("deserialize notifications=true"); - assert!(matches!( - parsed.tui.notifications, - Notifications::Enabled(true) - )); + assert_matches!(parsed.tui.notifications, Notifications::Enabled(true)); } #[test] @@ -2264,9 +2262,9 @@ mod notifications_tests { "#; let parsed: RootTomlTest = toml::from_str(toml).expect("deserialize notifications=[\"foo\"]"); - assert!(matches!( + assert_matches!( parsed.tui.notifications, Notifications::Custom(ref v) if v == &vec!["foo".to_string()] - )); + ); } } diff --git a/codex-rs/core/src/conversation_manager.rs b/codex-rs/core/src/conversation_manager.rs index b2159612b5..aeb0780725 100644 --- a/codex-rs/core/src/conversation_manager.rs +++ b/codex-rs/core/src/conversation_manager.rs @@ -210,6 +210,7 @@ fn truncate_before_nth_user_message(history: InitialHistory, n: usize) -> Initia mod tests { use super::*; use crate::codex::make_session_and_context; + use assert_matches::assert_matches; use codex_protocol::models::ContentItem; use codex_protocol::models::ReasoningItemReasoningSummary; use codex_protocol::models::ResponseItem; @@ -283,7 +284,7 @@ mod tests { .map(RolloutItem::ResponseItem) .collect(); let truncated2 = truncate_before_nth_user_message(InitialHistory::Forked(initial2), 2); - assert!(matches!(truncated2, InitialHistory::New)); + assert_matches!(truncated2, InitialHistory::New); } #[test] diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 3ae7f78516..cbbf1f444f 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -127,6 +127,7 @@ mod tests { use super::map_response_item_to_event_messages; use crate::protocol::EventMsg; use crate::protocol::InputMessageKind; + use assert_matches::assert_matches; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use pretty_assertions::assert_eq; @@ -158,7 +159,7 @@ mod tests { match &events[0] { EventMsg::UserMessage(user) => { assert_eq!(user.message, "Hello world"); - assert!(matches!(user.kind, Some(InputMessageKind::Plain))); + assert_matches!(user.kind, Some(InputMessageKind::Plain)); assert_eq!(user.images, Some(vec![img1, img2])); } other => panic!("expected UserMessage, got {other:?}"), diff --git a/codex-rs/core/tests/chat_completions_sse.rs b/codex-rs/core/tests/chat_completions_sse.rs index d8a8212932..1aab6ac38c 100644 --- a/codex-rs/core/tests/chat_completions_sse.rs +++ b/codex-rs/core/tests/chat_completions_sse.rs @@ -1,3 +1,4 @@ +use assert_matches::assert_matches; use std::sync::Arc; use tracing_test::traced_test; @@ -178,7 +179,7 @@ async fn streams_text_without_reasoning() { other => panic!("expected terminal message, got {other:?}"), } - assert!(matches!(events[2], ResponseEvent::Completed { .. })); + assert_matches!(events[2], ResponseEvent::Completed { .. }); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -219,7 +220,7 @@ async fn streams_reasoning_from_string_delta() { other => panic!("expected message item, got {other:?}"), } - assert!(matches!(events[4], ResponseEvent::Completed { .. })); + assert_matches!(events[4], ResponseEvent::Completed { .. }); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -266,7 +267,7 @@ async fn streams_reasoning_from_object_delta() { other => panic!("expected message item, got {other:?}"), } - assert!(matches!(events[5], ResponseEvent::Completed { .. })); + assert_matches!(events[5], ResponseEvent::Completed { .. }); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -293,7 +294,7 @@ async fn streams_reasoning_from_final_message() { other => panic!("expected reasoning item, got {other:?}"), } - assert!(matches!(events[2], ResponseEvent::Completed { .. })); + assert_matches!(events[2], ResponseEvent::Completed { .. }); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -337,7 +338,7 @@ async fn streams_reasoning_before_tool_call() { other => panic!("expected function call, got {other:?}"), } - assert!(matches!(events[3], ResponseEvent::Completed { .. })); + assert_matches!(events[3], ResponseEvent::Completed { .. }); } #[tokio::test] diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index fc74c412eb..74cce5cfc7 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -1,5 +1,6 @@ #![cfg(not(target_os = "windows"))] +use assert_matches::assert_matches; use codex_core::model_family::find_family_for_model; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; @@ -186,9 +187,9 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { assert_eq!(update.explanation.as_deref(), Some("Tool harness check")); assert_eq!(update.plan.len(), 2); assert_eq!(update.plan[0].step, "Inspect workspace"); - assert!(matches!(update.plan[0].status, StepStatus::InProgress)); + assert_matches!(update.plan[0].status, StepStatus::InProgress); assert_eq!(update.plan[1].step, "Report results"); - assert!(matches!(update.plan[1].status, StepStatus::Pending)); + assert_matches!(update.plan[1].status, StepStatus::Pending); false } EventMsg::TaskComplete(_) => true, diff --git a/codex-rs/git-tooling/Cargo.toml b/codex-rs/git-tooling/Cargo.toml index 674a5e527f..183221f3e1 100644 --- a/codex-rs/git-tooling/Cargo.toml +++ b/codex-rs/git-tooling/Cargo.toml @@ -17,4 +17,5 @@ walkdir = "2" workspace = true [dev-dependencies] +assert_matches = { workspace = true } pretty_assertions = "1.4.1" diff --git a/codex-rs/git-tooling/src/ghost_commits.rs b/codex-rs/git-tooling/src/ghost_commits.rs index 06b4321122..6c2a86166c 100644 --- a/codex-rs/git-tooling/src/ghost_commits.rs +++ b/codex-rs/git-tooling/src/ghost_commits.rs @@ -186,6 +186,7 @@ fn default_commit_identity() -> Vec<(OsString, OsString)> { mod tests { use super::*; use crate::operations::run_git_for_stdout; + use assert_matches::assert_matches; use pretty_assertions::assert_eq; use std::process::Command; @@ -348,7 +349,7 @@ mod tests { let options = CreateGhostCommitOptions::new(repo) .force_include(vec![PathBuf::from("../outside.txt")]); let err = create_ghost_commit(&options).unwrap_err(); - assert!(matches!(err, GitToolingError::PathEscapesRepository { .. })); + assert_matches!(err, GitToolingError::PathEscapesRepository { .. }); } #[test] @@ -356,7 +357,7 @@ mod tests { fn restore_requires_git_repository() { let temp = tempfile::tempdir().expect("tempdir"); let err = restore_to_commit(temp.path(), "deadbeef").unwrap_err(); - assert!(matches!(err, GitToolingError::NotAGitRepository { .. })); + assert_matches!(err, GitToolingError::NotAGitRepository { .. }); } #[test] diff --git a/codex-rs/ollama/Cargo.toml b/codex-rs/ollama/Cargo.toml index 587a193069..14dd6d2fcd 100644 --- a/codex-rs/ollama/Cargo.toml +++ b/codex-rs/ollama/Cargo.toml @@ -28,3 +28,4 @@ tracing = { workspace = true, features = ["log"] } wiremock = { workspace = true } [dev-dependencies] +assert_matches = { workspace = true } diff --git a/codex-rs/ollama/src/parser.rs b/codex-rs/ollama/src/parser.rs index c39df6680f..87f5b93725 100644 --- a/codex-rs/ollama/src/parser.rs +++ b/codex-rs/ollama/src/parser.rs @@ -30,19 +30,21 @@ pub(crate) fn pull_events_from_value(value: &JsonValue) -> Vec { #[cfg(test)] mod tests { + use assert_matches::assert_matches; + use super::*; #[test] fn test_pull_events_decoder_status_and_success() { let v: JsonValue = serde_json::json!({"status":"verifying"}); let events = pull_events_from_value(&v); - assert!(matches!(events.as_slice(), [PullEvent::Status(s)] if s == "verifying")); + assert_matches!(events.as_slice(), [PullEvent::Status(s)] if s == "verifying"); let v2: JsonValue = serde_json::json!({"status":"success"}); let events2 = pull_events_from_value(&v2); assert_eq!(events2.len(), 2); - assert!(matches!(events2[0], PullEvent::Status(ref s) if s == "success")); - assert!(matches!(events2[1], PullEvent::Success)); + assert_matches!(events2[0], PullEvent::Status(ref s) if s == "success"); + assert_matches!(events2[1], PullEvent::Success); } #[test] @@ -50,33 +52,24 @@ mod tests { let v: JsonValue = serde_json::json!({"digest":"sha256:abc","total":100}); let events = pull_events_from_value(&v); assert_eq!(events.len(), 1); - match &events[0] { + assert_matches!( + &events[0], PullEvent::ChunkProgress { digest, total, completed, - } => { - assert_eq!(digest, "sha256:abc"); - assert_eq!(*total, Some(100)); - assert_eq!(*completed, None); - } - _ => panic!("expected ChunkProgress"), - } - + } if digest == "sha256:abc" && total == &Some(100) && completed.is_none() + ); let v2: JsonValue = serde_json::json!({"digest":"sha256:def","completed":42}); let events2 = pull_events_from_value(&v2); assert_eq!(events2.len(), 1); - match &events2[0] { + assert_matches!( + &events2[0], PullEvent::ChunkProgress { digest, total, completed, - } => { - assert_eq!(digest, "sha256:def"); - assert_eq!(*total, None); - assert_eq!(*completed, Some(42)); - } - _ => panic!("expected ChunkProgress"), - } + } if digest == "sha256:def" && total.is_none() && completed == &Some(42) + ); } } diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 7356ae334b..f42f555c9e 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -94,6 +94,7 @@ arboard = { workspace = true } [dev-dependencies] +assert_matches = { workspace = true } chrono = { workspace = true, features = ["serde"] } insta = { workspace = true } pretty_assertions = { workspace = true } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index a17e0a757a..0ab31daa32 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -3,6 +3,7 @@ use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::test_backend::VT100Backend; use crate::tui::FrameRequester; +use assert_matches::assert_matches; use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::config::Config; @@ -634,7 +635,7 @@ fn streaming_final_answer_keeps_task_running_state() { chat.queued_user_messages.front().unwrap().text, "queued submission" ); - assert!(matches!(op_rx.try_recv(), Err(TryRecvError::Empty))); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); match op_rx.try_recv() { @@ -1791,10 +1792,7 @@ fn apply_patch_approval_sends_op_with_submission_id() { while let Ok(app_ev) = rx.try_recv() { if let AppEvent::CodexOp(Op::PatchApproval { id, decision }) = app_ev { assert_eq!(id, "sub-123"); - assert!(matches!( - decision, - codex_core::protocol::ReviewDecision::Approved - )); + assert_matches!(decision, codex_core::protocol::ReviewDecision::Approved); found = true; break; } @@ -1841,10 +1839,7 @@ fn apply_patch_full_flow_integration_like() { match forwarded { Op::PatchApproval { id, decision } => { assert_eq!(id, "sub-xyz"); - assert!(matches!( - decision, - codex_core::protocol::ReviewDecision::Approved - )); + assert_matches!(decision, codex_core::protocol::ReviewDecision::Approved); } other => panic!("unexpected op forwarded: {other:?}"), } diff --git a/codex-rs/utils/readiness/Cargo.toml b/codex-rs/utils/readiness/Cargo.toml index faa5d4d13f..cb76b52b79 100644 --- a/codex-rs/utils/readiness/Cargo.toml +++ b/codex-rs/utils/readiness/Cargo.toml @@ -10,6 +10,7 @@ time = { workspace = true } tokio = { workspace = true, features = ["sync", "time"] } [dev-dependencies] +assert_matches = { workspace = true } tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } [lints] diff --git a/codex-rs/utils/readiness/src/lib.rs b/codex-rs/utils/readiness/src/lib.rs index 9ff15e01b3..ac3a323fae 100644 --- a/codex-rs/utils/readiness/src/lib.rs +++ b/codex-rs/utils/readiness/src/lib.rs @@ -176,6 +176,7 @@ mod tests { use super::ReadinessFlag; use super::Token; use super::errors::ReadinessError; + use assert_matches::assert_matches; #[tokio::test] async fn subscribe_and_mark_ready_roundtrip() -> Result<(), ReadinessError> { @@ -244,6 +245,6 @@ mod tests { .subscribe() .await .expect_err("contended subscribe should report a lock failure"); - assert!(matches!(err, ReadinessError::TokenLockFailed)); + assert_matches!(err, ReadinessError::TokenLockFailed); } } From 53504a38d2a89b09daa2e2d9433fa6e35c2241e0 Mon Sep 17 00:00:00 2001 From: pranavdesh Date: Sun, 5 Oct 2025 14:43:34 -0700 Subject: [PATCH 04/21] Expand TypeScript SDK README (#4779) ## Summary - expand the TypeScript SDK README with streaming, architecture, and API docs - refresh quick start examples and clarify thread management options ## Testing - Not Run (docs only) --------- Co-authored-by: pakrym-oai --- sdk/typescript/README.md | 60 +++++++++++++++++++--------------------- 1 file changed, 29 insertions(+), 31 deletions(-) diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 9d2ea97734..35c0c8f485 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -1,6 +1,8 @@ # Codex SDK -Bring the power of the best coding agent to your application. +Embed the Codex agent in your workflows and apps. + +The TypeScript SDK wraps the bundled `codex` binary. It spawns the CLI and exchanges JSONL events over stdin/stdout. ## Installation @@ -8,68 +10,64 @@ Bring the power of the best coding agent to your application. npm install @openai/codex-sdk ``` -## Usage +Requires Node.js 18+. -Call `startThread()` and `run()` to start a thread with Codex. +## Quickstart ```typescript import { Codex } from "@openai/codex-sdk"; const codex = new Codex(); const thread = codex.startThread(); -const result = await thread.run("Diagnose the test failure and propose a fix"); +const turn = await thread.run("Diagnose the test failure and propose a fix"); -console.log(result); +console.log(turn.finalResponse); +console.log(turn.items); ``` -You can call `run()` again to continue the same thread. +Call `run()` repeatedly on the same `Thread` instance to continue that conversation. ```typescript -const result = await thread.run("Implement the fix"); - -console.log(result); +const nextTurn = await thread.run("Implement the fix"); ``` -### Streaming +### Streaming responses -The `run()` method completes when a thread turn is complete and the agent has produced the final response. - -You can stream events while they are being produced by calling `runStreamed()` and iterating the returned generator. +`run()` buffers events until the turn finishes. To react to intermediate progress—tool calls, streaming responses, and file diffs—use `runStreamed()` instead, which returns an async generator of structured events. ```typescript const { events } = await thread.runStreamed("Diagnose the test failure and propose a fix"); for await (const event of events) { - console.log(event); + switch (event.type) { + case "item.completed": + console.log("item", event.item); + break; + case "turn.completed": + console.log("usage", event.usage); + break; + } } ``` -### Resuming a thread +### Resuming an existing thread -If you don't have the original `Thread` instance to continue the thread, you can resume by calling `resumeThread()` and providing the thread identifier. +Threads are persisted in `~/.codex/sessions`. If you lose the in-memory `Thread` object, reconstruct it with `resumeThread()` and keep going. ```typescript -const threadId = "..."; -const thread = codex.resumeThread(threadId); -const result = await thread.run("Implement the fix"); - -console.log(result); +const savedThreadId = process.env.CODEX_THREAD_ID!; +const thread = codex.resumeThread(savedThreadId); +await thread.run("Implement the fix"); ``` -### Working directory +### Working directory controls -By default, Codex will run in the current working directory. You can change the working directory by passing the `workingDirectory` option when creating a thread. - -```typescript -const thread = codex.startThread({ - workingDirectory: "/path/to/working/directory", -}); -``` - -To avoid unrecoverable errors, Codex requires the working directory to be a Git repository. You can skip the Git repository check by passing the `skipGitRepoCheck` option when creating a thread. +Codex runs in the current working directory by default. To avoid unrecoverable errors, Codex requires the working directory to be a Git repository. You can skip the Git repository check by passing the `skipGitRepoCheck` option when creating a thread. ```typescript const thread = codex.startThread({ + workingDirectory: "/path/to/project", skipGitRepoCheck: true, }); ``` + From 191d620707db60f18bdda9d2f5d17b87a67439b5 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Sun, 5 Oct 2025 14:58:16 -0700 Subject: [PATCH 05/21] Use response helpers when mounting SSE test responses (#4783) ## Summary - replace manual wiremock SSE mounts in the compact suite with the shared response helpers - simplify the exec auth_env integration test by using the mount_sse_once_match helper - rely on mount_sse_sequence plus server request collection to replace the bespoke SeqResponder utility in tests ## Testing - just fmt ------ https://chatgpt.com/codex/tasks/task_i_68e2e238f2a88320a337f0b9e4098093 --- codex-rs/core/tests/suite/compact.rs | 127 ++++---------------------- codex-rs/exec/tests/suite/auth_env.rs | 18 ++-- 2 files changed, 23 insertions(+), 122 deletions(-) diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index a8515296e8..f6db083407 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -13,12 +13,6 @@ use core_test_support::load_default_config_for_test; use core_test_support::skip_if_no_network; use core_test_support::wait_for_event; use tempfile::TempDir; -use wiremock::Mock; -use wiremock::Request; -use wiremock::Respond; -use wiremock::ResponseTemplate; -use wiremock::matchers::method; -use wiremock::matchers::path; use codex_core::codex::compact::SUMMARIZATION_PROMPT; use core_test_support::responses::ev_assistant_message; @@ -26,14 +20,10 @@ use core_test_support::responses::ev_completed; use core_test_support::responses::ev_completed_with_tokens; use core_test_support::responses::ev_function_call; use core_test_support::responses::mount_sse_once_match; +use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; -use core_test_support::responses::sse_response; use core_test_support::responses::start_mock_server; use pretty_assertions::assert_eq; -use std::sync::Arc; -use std::sync::Mutex; -use std::sync::atomic::AtomicUsize; -use std::sync::atomic::Ordering; // --- Test helpers ----------------------------------------------------------- pub(super) const FIRST_REPLY: &str = "FIRST_REPLY"; @@ -295,12 +285,7 @@ async fn auto_compact_runs_after_token_limit_hit() { && !body.contains(SECOND_AUTO_MSG) && !body.contains("You have exceeded the maximum number of tokens") }; - Mock::given(method("POST")) - .and(path("/v1/responses")) - .and(first_matcher) - .respond_with(sse_response(sse1)) - .mount(&server) - .await; + mount_sse_once_match(&server, first_matcher, sse1).await; let second_matcher = |req: &wiremock::Request| { let body = std::str::from_utf8(&req.body).unwrap_or(""); @@ -308,23 +293,13 @@ async fn auto_compact_runs_after_token_limit_hit() { && body.contains(FIRST_AUTO_MSG) && !body.contains("You have exceeded the maximum number of tokens") }; - Mock::given(method("POST")) - .and(path("/v1/responses")) - .and(second_matcher) - .respond_with(sse_response(sse2)) - .mount(&server) - .await; + mount_sse_once_match(&server, second_matcher, sse2).await; let third_matcher = |req: &wiremock::Request| { let body = std::str::from_utf8(&req.body).unwrap_or(""); body.contains("You have exceeded the maximum number of tokens") }; - Mock::given(method("POST")) - .and(path("/v1/responses")) - .and(third_matcher) - .respond_with(sse_response(sse3)) - .mount(&server) - .await; + mount_sse_once_match(&server, third_matcher, sse3).await; let model_provider = ModelProviderInfo { base_url: Some(format!("{}/v1", server.uri())), @@ -455,12 +430,7 @@ async fn auto_compact_persists_rollout_entries() { && !body.contains(SECOND_AUTO_MSG) && !body.contains("You have exceeded the maximum number of tokens") }; - Mock::given(method("POST")) - .and(path("/v1/responses")) - .and(first_matcher) - .respond_with(sse_response(sse1)) - .mount(&server) - .await; + mount_sse_once_match(&server, first_matcher, sse1).await; let second_matcher = |req: &wiremock::Request| { let body = std::str::from_utf8(&req.body).unwrap_or(""); @@ -468,23 +438,13 @@ async fn auto_compact_persists_rollout_entries() { && body.contains(FIRST_AUTO_MSG) && !body.contains("You have exceeded the maximum number of tokens") }; - Mock::given(method("POST")) - .and(path("/v1/responses")) - .and(second_matcher) - .respond_with(sse_response(sse2)) - .mount(&server) - .await; + mount_sse_once_match(&server, second_matcher, sse2).await; let third_matcher = |req: &wiremock::Request| { let body = std::str::from_utf8(&req.body).unwrap_or(""); body.contains("You have exceeded the maximum number of tokens") }; - Mock::given(method("POST")) - .and(path("/v1/responses")) - .and(third_matcher) - .respond_with(sse_response(sse3)) - .mount(&server) - .await; + mount_sse_once_match(&server, third_matcher, sse3).await; let model_provider = ModelProviderInfo { base_url: Some(format!("{}/v1", server.uri())), @@ -582,35 +542,20 @@ async fn auto_compact_stops_after_failed_attempt() { body.contains(FIRST_AUTO_MSG) && !body.contains("You have exceeded the maximum number of tokens") }; - Mock::given(method("POST")) - .and(path("/v1/responses")) - .and(first_matcher) - .respond_with(sse_response(sse1.clone())) - .mount(&server) - .await; + mount_sse_once_match(&server, first_matcher, sse1.clone()).await; let second_matcher = |req: &wiremock::Request| { let body = std::str::from_utf8(&req.body).unwrap_or(""); body.contains("You have exceeded the maximum number of tokens") }; - Mock::given(method("POST")) - .and(path("/v1/responses")) - .and(second_matcher) - .respond_with(sse_response(sse2.clone())) - .mount(&server) - .await; + mount_sse_once_match(&server, second_matcher, sse2.clone()).await; let third_matcher = |req: &wiremock::Request| { let body = std::str::from_utf8(&req.body).unwrap_or(""); !body.contains("You have exceeded the maximum number of tokens") && body.contains(SUMMARY_TEXT) }; - Mock::given(method("POST")) - .and(path("/v1/responses")) - .and(third_matcher) - .respond_with(sse_response(sse3.clone())) - .mount(&server) - .await; + mount_sse_once_match(&server, third_matcher, sse3.clone()).await; let model_provider = ModelProviderInfo { base_url: Some(format!("{}/v1", server.uri())), @@ -708,49 +653,7 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ ev_completed_with_tokens("r6", 120), ]); - #[derive(Clone)] - struct SeqResponder { - bodies: Arc>, - calls: Arc, - requests: Arc>>>, - } - - impl SeqResponder { - fn new(bodies: Vec) -> Self { - Self { - bodies: Arc::new(bodies), - calls: Arc::new(AtomicUsize::new(0)), - requests: Arc::new(Mutex::new(Vec::new())), - } - } - - fn recorded_requests(&self) -> Vec> { - self.requests.lock().unwrap().clone() - } - } - - impl Respond for SeqResponder { - fn respond(&self, req: &Request) -> ResponseTemplate { - let idx = self.calls.fetch_add(1, Ordering::SeqCst); - self.requests.lock().unwrap().push(req.body.clone()); - let body = self - .bodies - .get(idx) - .unwrap_or_else(|| panic!("unexpected request index {idx}")) - .clone(); - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_raw(body, "text/event-stream") - } - } - - let responder = SeqResponder::new(vec![sse1, sse2, sse3, sse4, sse5, sse6]); - Mock::given(method("POST")) - .and(path("/v1/responses")) - .respond_with(responder.clone()) - .expect(6) - .mount(&server) - .await; + mount_sse_sequence(&server, vec![sse1, sse2, sse3, sse4, sse5, sse6]).await; let model_provider = ModelProviderInfo { base_url: Some(format!("{}/v1", server.uri())), @@ -801,10 +704,12 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ "auto compact should not emit task lifecycle events" ); - let request_bodies: Vec = responder - .recorded_requests() + let request_bodies: Vec = server + .received_requests() + .await + .unwrap() .into_iter() - .map(|body| String::from_utf8(body).unwrap_or_default()) + .map(|request| String::from_utf8(request.body).unwrap_or_default()) .collect(); assert_eq!( request_bodies.len(), diff --git a/codex-rs/exec/tests/suite/auth_env.rs b/codex-rs/exec/tests/suite/auth_env.rs index d59f46cde8..91d7bad8f8 100644 --- a/codex-rs/exec/tests/suite/auth_env.rs +++ b/codex-rs/exec/tests/suite/auth_env.rs @@ -1,26 +1,22 @@ #![allow(clippy::unwrap_used, clippy::expect_used)] use core_test_support::responses::ev_completed; +use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::sse; -use core_test_support::responses::sse_response; use core_test_support::responses::start_mock_server; use core_test_support::test_codex_exec::test_codex_exec; -use wiremock::Mock; use wiremock::matchers::header; -use wiremock::matchers::method; -use wiremock::matchers::path; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn exec_uses_codex_api_key_env_var() -> anyhow::Result<()> { let test = test_codex_exec(); let server = start_mock_server().await; - Mock::given(method("POST")) - .and(path("/v1/responses")) - .and(header("Authorization", "Bearer dummy")) - .respond_with(sse_response(sse(vec![ev_completed("request_0")]))) - .expect(1) - .mount(&server) - .await; + mount_sse_once_match( + &server, + header("Authorization", "Bearer dummy"), + sse(vec![ev_completed("request_0")]), + ) + .await; test.cmd_with_server(&server) .arg("--skip-git-repo-check") From 7fa5e95c1f78364aecada6888112045fede22c8e Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Sun, 5 Oct 2025 15:12:37 -0700 Subject: [PATCH 06/21] [MCP] Upgrade rmcp to 0.8 (#4774) The version with the well-known discovery and my MCP client name change were just released https://github.com/modelcontextprotocol/rust-sdk/releases --- codex-rs/Cargo.lock | 10 ++++++---- codex-rs/Cargo.toml | 5 +---- codex-rs/rmcp-client/src/perform_oauth_login.rs | 4 +++- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 29025095bc..6b4353d8a1 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4770,8 +4770,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "0.7.0" -source = "git+https://github.com/modelcontextprotocol/rust-sdk?rev=c0b777c7f784ba2d456b03c2ec3b98c9b28b5e10#c0b777c7f784ba2d456b03c2ec3b98c9b28b5e10" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "583d060e99feb3a3683fb48a1e4bf5f8d4a50951f429726f330ee5ff548837f8" dependencies = [ "base64", "bytes", @@ -4803,8 +4804,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "0.7.0" -source = "git+https://github.com/modelcontextprotocol/rust-sdk?rev=c0b777c7f784ba2d456b03c2ec3b98c9b28b5e10#c0b777c7f784ba2d456b03c2ec3b98c9b28b5e10" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "421d8b0ba302f479214889486f9550e63feca3af310f1190efcf6e2016802693" dependencies = [ "darling 0.21.3", "proc-macro2", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index d796e96783..bcbe844509 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -143,8 +143,7 @@ rand = "0.9" ratatui = "0.29.0" regex-lite = "0.1.7" reqwest = "0.12" -# When this gets bumped, remove the patch below. -rmcp = { version = "0.7.0", default-features = false } +rmcp = { version = "0.8.0", default-features = false } schemars = "0.8.22" seccompiler = "0.5.0" serde = "1" @@ -251,5 +250,3 @@ ratatui = { git = "https://github.com/nornagon/ratatui", branch = "nornagon-v0.2 # Uncomment to debug local changes. # rmcp = { path = "../../rust-sdk/crates/rmcp" } -# 0.7.0 + https://github.com/modelcontextprotocol/rust-sdk/pull/459 which includes dynamic .well-known discovery which is required for Figma. -rmcp = { git = "https://github.com/modelcontextprotocol/rust-sdk", rev = "c0b777c7f784ba2d456b03c2ec3b98c9b28b5e10", default-features = false } diff --git a/codex-rs/rmcp-client/src/perform_oauth_login.rs b/codex-rs/rmcp-client/src/perform_oauth_login.rs index 0fcdb2f617..b5a893611a 100644 --- a/codex-rs/rmcp-client/src/perform_oauth_login.rs +++ b/codex-rs/rmcp-client/src/perform_oauth_login.rs @@ -47,7 +47,9 @@ pub async fn perform_oauth_login(server_name: &str, server_url: &str) -> Result< spawn_callback_server(server, tx); let mut oauth_state = OAuthState::new(server_url, None).await?; - oauth_state.start_authorization(&[], &redirect_uri).await?; + oauth_state + .start_authorization(&[], &redirect_uri, Some("Codex")) + .await?; let auth_url = oauth_state.get_authorization_url().await?; println!("Authorize `{server_name}` by opening this URL in your browser:\n{auth_url}\n"); From 77a8b7fdeb4f9eff816acc14d0e96d3d92e61210 Mon Sep 17 00:00:00 2001 From: Fouad Matin <169186268+fouad-openai@users.noreply.github.com> Date: Sun, 5 Oct 2025 15:51:57 -0700 Subject: [PATCH 07/21] add `codex sandbox {linux|macos}` (#4782) ## Summary - add a `codex sandbox` subcommand with macOS and Linux targets while keeping the legacy `codex debug` aliases - update documentation to highlight the new sandbox entrypoints and point existing references to the new command - clarify the core README about the linux sandbox helper alias ## Testing - just fmt - just fix -p codex-cli - cargo test -p codex-cli ------ https://chatgpt.com/codex/tasks/task_i_68e2e00ca1e8832d8bff53aa0b50b49e --- codex-rs/README.md | 6 +++++- codex-rs/cli/src/main.rs | 23 +++++++++++++---------- codex-rs/core/README.md | 2 +- docs/sandbox.md | 6 +++++- 4 files changed, 24 insertions(+), 13 deletions(-) diff --git a/codex-rs/README.md b/codex-rs/README.md index 46eda63a1e..f5a5b838fc 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -71,9 +71,13 @@ To test to see what happens when a command is run under the sandbox provided by ``` # macOS -codex debug seatbelt [--full-auto] [COMMAND]... +codex sandbox macos [--full-auto] [COMMAND]... # Linux +codex sandbox linux [--full-auto] [COMMAND]... + +# Legacy aliases +codex debug seatbelt [--full-auto] [COMMAND]... codex debug landlock [--full-auto] [COMMAND]... ``` diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 79a187938a..04a7ff88f3 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -76,8 +76,9 @@ enum Subcommand { /// Generate shell completion scripts. Completion(CompletionCommand), - /// Internal debugging commands. - Debug(DebugArgs), + /// Run commands within a Codex-provided sandbox. + #[clap(visible_alias = "debug")] + Sandbox(SandboxArgs), /// Apply the latest diff produced by Codex agent as a `git apply` to your local working tree. #[clap(visible_alias = "a")] @@ -121,18 +122,20 @@ struct ResumeCommand { } #[derive(Debug, Parser)] -struct DebugArgs { +struct SandboxArgs { #[command(subcommand)] - cmd: DebugCommand, + cmd: SandboxCommand, } #[derive(Debug, clap::Subcommand)] -enum DebugCommand { +enum SandboxCommand { /// Run a command under Seatbelt (macOS only). - Seatbelt(SeatbeltCommand), + #[clap(visible_alias = "seatbelt")] + Macos(SeatbeltCommand), /// Run a command under Landlock+seccomp (Linux only). - Landlock(LandlockCommand), + #[clap(visible_alias = "landlock")] + Linux(LandlockCommand), } #[derive(Debug, Parser)] @@ -341,8 +344,8 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() ); codex_cloud_tasks::run_main(cloud_cli, codex_linux_sandbox_exe).await?; } - Some(Subcommand::Debug(debug_args)) => match debug_args.cmd { - DebugCommand::Seatbelt(mut seatbelt_cli) => { + Some(Subcommand::Sandbox(sandbox_args)) => match sandbox_args.cmd { + SandboxCommand::Macos(mut seatbelt_cli) => { prepend_config_flags( &mut seatbelt_cli.config_overrides, root_config_overrides.clone(), @@ -353,7 +356,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() ) .await?; } - DebugCommand::Landlock(mut landlock_cli) => { + SandboxCommand::Linux(mut landlock_cli) => { prepend_config_flags( &mut landlock_cli.config_overrides, root_config_overrides.clone(), diff --git a/codex-rs/core/README.md b/codex-rs/core/README.md index 9a4c255abe..8260fc1e79 100644 --- a/codex-rs/core/README.md +++ b/codex-rs/core/README.md @@ -12,7 +12,7 @@ Expects `/usr/bin/sandbox-exec` to be present. ### Linux -Expects the binary containing `codex-core` to run the equivalent of `codex debug landlock` when `arg0` is `codex-linux-sandbox`. See the `codex-arg0` crate for details. +Expects the binary containing `codex-core` to run the equivalent of `codex sandbox linux` (legacy alias: `codex debug landlock`) when `arg0` is `codex-linux-sandbox`. See the `codex-arg0` crate for details. ### All Platforms diff --git a/docs/sandbox.md b/docs/sandbox.md index 35f83eb7d3..711ecc8f25 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -69,9 +69,13 @@ To test to see what happens when a command is run under the sandbox provided by ``` # macOS -codex debug seatbelt [--full-auto] [COMMAND]... +codex sandbox macos [--full-auto] [COMMAND]... # Linux +codex sandbox linux [--full-auto] [COMMAND]... + +# Legacy aliases +codex debug seatbelt [--full-auto] [COMMAND]... codex debug landlock [--full-auto] [COMMAND]... ``` From b2d81a7cacde4b512036c28652f14a0b7452b3ce Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Sun, 5 Oct 2025 16:01:38 -0700 Subject: [PATCH 08/21] Make output assertions more explicit (#4784) Match using precise regexes. --- codex-rs/Cargo.lock | 1 + codex-rs/core/tests/common/Cargo.toml | 1 + codex-rs/core/tests/common/lib.rs | 11 ++ .../core/tests/suite/shell_serialization.rs | 57 +++++----- codex-rs/core/tests/suite/tool_harness.rs | 6 +- codex-rs/core/tests/suite/tools.rs | 100 +++++++++--------- 6 files changed, 95 insertions(+), 81 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 6b4353d8a1..0a650511e3 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1575,6 +1575,7 @@ dependencies = [ "anyhow", "assert_cmd", "codex-core", + "regex-lite", "serde_json", "tempfile", "tokio", diff --git a/codex-rs/core/tests/common/Cargo.toml b/codex-rs/core/tests/common/Cargo.toml index ed8bc93a12..6ecc54937f 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 } +regex-lite = { workspace = true } serde_json = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["time"] } diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index 500c595545..2c012b9b35 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -6,6 +6,7 @@ use codex_core::CodexConversation; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; +use regex_lite::Regex; #[cfg(target_os = "linux")] use assert_cmd::cargo::cargo_bin; @@ -14,6 +15,16 @@ pub mod responses; pub mod test_codex; pub mod test_codex_exec; +#[track_caller] +pub fn assert_regex_match<'s>(pattern: &str, actual: &'s str) -> regex_lite::Captures<'s> { + let regex = Regex::new(pattern).unwrap_or_else(|err| { + panic!("failed to compile regex {pattern:?}: {err}"); + }); + regex + .captures(actual) + .unwrap_or_else(|| panic!("regex {pattern:?} did not match {actual:?}")) +} + /// Returns a default `Config` whose on-disk state is confined to the provided /// temporary directory. Using a per-test directory keeps tests hermetic and /// avoids clobbering a developer’s real `~/.codex`. diff --git a/codex-rs/core/tests/suite/shell_serialization.rs b/codex-rs/core/tests/suite/shell_serialization.rs index 8e4a4757e8..4e29215fd7 100644 --- a/codex-rs/core/tests/suite/shell_serialization.rs +++ b/codex-rs/core/tests/suite/shell_serialization.rs @@ -8,6 +8,7 @@ use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; +use core_test_support::assert_regex_match; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; @@ -131,10 +132,7 @@ async fn shell_output_stays_json_without_freeform_apply_patch() -> Result<()> { .get("output") .and_then(Value::as_str) .unwrap_or_default(); - assert!( - stdout.contains("shell json"), - "expected stdout to include command output, got {stdout:?}" - ); + assert_regex_match(r"(?s)^shell json\n?$", stdout); Ok(()) } @@ -190,18 +188,12 @@ async fn shell_output_is_structured_with_freeform_apply_patch() -> Result<()> { serde_json::from_str::(output).is_err(), "expected structured shell output to be plain text", ); - assert!( - output.starts_with("Exit code: 0\n"), - "expected exit code prefix, got {output:?}", - ); - assert!( - output.contains("\nOutput:\n"), - "expected Output section, got {output:?}" - ); - assert!( - output.contains("freeform shell"), - "expected stdout content, got {output:?}" - ); + let expected_pattern = r"(?s)^Exit code: 0 +Wall time: [0-9]+(?:\.[0-9]+)? seconds +Output: +freeform shell +?$"; + assert_regex_match(expected_pattern, output); Ok(()) } @@ -259,18 +251,27 @@ async fn shell_output_reserializes_truncated_content() -> Result<()> { serde_json::from_str::(output).is_err(), "expected truncated shell output to be plain text", ); - assert!( - output.starts_with("Exit code: 0\n"), - "expected exit code prefix, got {output:?}", - ); - assert!( - output.lines().any(|line| line == "Total output lines: 400"), - "expected total output lines marker, got {output:?}", - ); - assert!( - output.contains("[... omitted"), - "expected truncated marker, got {output:?}", - ); + let truncated_pattern = r#"(?s)^Exit code: 0 +Wall time: [0-9]+(?:\.[0-9]+)? seconds +Total output lines: 400 +Output: +Total output lines: 400 + +1 +2 +3 +4 +5 +6 +.*\[\.{3} omitted \d+ of 400 lines \.{3}\] + +.*\n396 +397 +398 +399 +400 +$"#; + assert_regex_match(truncated_pattern, output); Ok(()) } diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 74cce5cfc7..14e0e1c892 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -9,6 +9,7 @@ use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::plan_tool::StepStatus; +use core_test_support::assert_regex_match; use core_test_support::responses; use core_test_support::responses::ev_apply_patch_function_call; use core_test_support::responses::ev_assistant_message; @@ -116,10 +117,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> let exec_output: Value = serde_json::from_str(output_text)?; assert_eq!(exec_output["metadata"]["exit_code"], 0); let stdout = exec_output["output"].as_str().expect("stdout field"); - assert!( - stdout.contains("tool harness"), - "expected stdout to contain command output, got {stdout:?}" - ); + assert_regex_match(r"(?s)^tool harness\n?$", stdout); Ok(()) } diff --git a/codex-rs/core/tests/suite/tools.rs b/codex-rs/core/tests/suite/tools.rs index a2cae76cab..e57cd0a556 100644 --- a/codex-rs/core/tests/suite/tools.rs +++ b/codex-rs/core/tests/suite/tools.rs @@ -9,6 +9,7 @@ use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; +use core_test_support::assert_regex_match; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_custom_tool_call; @@ -21,6 +22,7 @@ use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; +use regex_lite::Regex; use serde_json::Value; use serde_json::json; use wiremock::Request; @@ -254,10 +256,8 @@ async fn shell_escalated_permissions_rejected_then_ok() -> Result<()> { "expected exit code 0 after rerunning without escalation", ); let stdout = output_json["output"].as_str().unwrap_or_default(); - assert!( - stdout.contains("shell ok"), - "expected stdout to include command output, got {stdout:?}" - ); + let stdout_pattern = r"(?s)^shell ok\n?$"; + assert_regex_match(stdout_pattern, stdout); Ok(()) } @@ -437,15 +437,15 @@ async fn shell_timeout_includes_timeout_prefix_and_metadata() -> Result<()> { ); let stdout = output_json["output"].as_str().unwrap_or_default(); - assert!( - stdout.contains("command timed out after "), - "expected timeout prefix, got {stdout:?}" - ); - let third_line = stdout.lines().nth(2).unwrap_or_default(); - let duration_ms = third_line - .strip_prefix("command timed out after ") - .and_then(|line| line.strip_suffix(" milliseconds")) - .and_then(|value| value.parse::().ok()) + let timeout_pattern = r"(?s)^Total output lines: \d+ + +command timed out after (?P\d+) milliseconds +line +.*$"; + let captures = assert_regex_match(timeout_pattern, stdout); + let duration_ms = captures + .name("ms") + .and_then(|m| m.as_str().parse::().ok()) .unwrap_or_default(); assert!( duration_ms >= timeout_ms, @@ -453,14 +453,8 @@ async fn shell_timeout_includes_timeout_prefix_and_metadata() -> Result<()> { ); } else { // Fallback: accept the signal classification path to deflake the test. - assert!( - output_str.contains("execution error"), - "unexpected non-JSON output: {output_str:?}" - ); - assert!( - output_str.contains("Signal(") || output_str.to_lowercase().contains("signal"), - "expected signal classification in error output, got {output_str:?}" - ); + let signal_pattern = r"(?is)^execution error:.*signal.*$"; + assert_regex_match(signal_pattern, output_str); } Ok(()) @@ -518,30 +512,25 @@ async fn shell_sandbox_denied_truncates_error_output() -> Result<()> { .and_then(Value::as_str) .expect("denied output string"); - assert!( - output.contains("failed in sandbox: "), - "expected sandbox error prefix, got {output:?}" - ); - assert!( - output.contains("[... omitted"), - "expected truncated marker, got {output:?}" - ); - assert!( - output.contains(long_line), - "expected truncated stderr sample, got {output:?}" - ); - // Linux distributions may surface sandbox write failures as different errno messages - // depending on the underlying mechanism (e.g., EPERM, EACCES, or EROFS). Accept a - // small set of common variants to keep this cross-platform. - let denial_markers = [ - "Operation not permitted", // EPERM - "Permission denied", // EACCES - "Read-only file system", // EROFS - ]; - assert!( - denial_markers.iter().any(|m| output.contains(m)), - "expected sandbox denial message, got {output:?}" - ); + let sandbox_pattern = r#"(?s)^Exit code: -?\d+ +Wall time: [0-9]+(?:\.[0-9]+)? seconds +Total output lines: \d+ +Output: +Total output lines: \d+ + +failed in sandbox: .*?(?:Operation not permitted|Permission denied|Read-only file system).*? +\[\.{3} omitted \d+ of \d+ lines \.{3}\] +.*this is a long stderr line that should trigger truncation 0123456789abcdefghijklmnopqrstuvwxyz.* +\n?$"#; + let sandbox_regex = Regex::new(sandbox_pattern)?; + if !sandbox_regex.is_match(output) { + let fallback_pattern = r#"(?s)^Total output lines: \d+ + +failed in sandbox: this is a long stderr line that should trigger truncation 0123456789abcdefghijklmnopqrstuvwxyz +.*this is a long stderr line that should trigger truncation 0123456789abcdefghijklmnopqrstuvwxyz.* +.*(?:Operation not permitted|Permission denied|Read-only file system).*$"#; + assert_regex_match(fallback_pattern, output); + } Ok(()) } @@ -604,10 +593,23 @@ async fn shell_spawn_failure_truncates_exec_error() -> Result<()> { .and_then(Value::as_str) .expect("spawn failure output string"); - assert!( - output.contains("execution error:"), - "expected execution error prefix, got {output:?}" - ); + let spawn_error_pattern = r#"(?s)^Exit code: -?\d+ +Wall time: [0-9]+(?:\.[0-9]+)? seconds +Output: +execution error: .*$"#; + let spawn_truncated_pattern = r#"(?s)^Exit code: -?\d+ +Wall time: [0-9]+(?:\.[0-9]+)? seconds +Total output lines: \d+ +Output: +Total output lines: \d+ + +execution error: .*$"#; + let spawn_error_regex = Regex::new(spawn_error_pattern)?; + let spawn_truncated_regex = Regex::new(spawn_truncated_pattern)?; + if !spawn_error_regex.is_match(output) && !spawn_truncated_regex.is_match(output) { + let fallback_pattern = r"(?s)^execution error: .*$"; + assert_regex_match(fallback_pattern, output); + } assert!(output.len() <= 10 * 1024); Ok(()) From a90a58f7a16c2886a5b3e2bd34a90708c987ac58 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Sun, 5 Oct 2025 16:41:55 -0700 Subject: [PATCH 09/21] Trim double Total output lines (#4787) --- codex-rs/core/src/client_common.rs | 13 ++++++++++++- codex-rs/core/tests/suite/shell_serialization.rs | 8 ++++---- codex-rs/core/tests/suite/tools.rs | 2 -- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 5a3610629f..d30da5192a 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -159,12 +159,16 @@ fn build_structured_output(parsed: &ExecOutputJson) -> String { parsed.metadata.duration_seconds )); + let mut output = parsed.output.clone(); if let Some(total_lines) = extract_total_output_lines(&parsed.output) { sections.push(format!("Total output lines: {total_lines}")); + if let Some(stripped) = strip_total_output_header(&output) { + output = stripped.to_string(); + } } sections.push("Output:".to_string()); - sections.push(parsed.output.clone()); + sections.push(output); sections.join("\n") } @@ -177,6 +181,13 @@ fn extract_total_output_lines(output: &str) -> Option { total_segment.parse::().ok() } +fn strip_total_output_header(output: &str) -> Option<&str> { + let after_prefix = output.strip_prefix("Total output lines: ")?; + let (_, remainder) = after_prefix.split_once('\n')?; + let remainder = remainder.strip_prefix('\n').unwrap_or(remainder); + Some(remainder) +} + #[derive(Debug)] pub enum ResponseEvent { Created, diff --git a/codex-rs/core/tests/suite/shell_serialization.rs b/codex-rs/core/tests/suite/shell_serialization.rs index 4e29215fd7..67fc2786ca 100644 --- a/codex-rs/core/tests/suite/shell_serialization.rs +++ b/codex-rs/core/tests/suite/shell_serialization.rs @@ -255,17 +255,17 @@ async fn shell_output_reserializes_truncated_content() -> Result<()> { Wall time: [0-9]+(?:\.[0-9]+)? seconds Total output lines: 400 Output: -Total output lines: 400 - 1 2 3 4 5 6 -.*\[\.{3} omitted \d+ of 400 lines \.{3}\] +.* +\[\.{3} omitted \d+ of 400 lines \.{3}\] -.*\n396 +.* +396 397 398 399 diff --git a/codex-rs/core/tests/suite/tools.rs b/codex-rs/core/tests/suite/tools.rs index e57cd0a556..08826a1a72 100644 --- a/codex-rs/core/tests/suite/tools.rs +++ b/codex-rs/core/tests/suite/tools.rs @@ -516,7 +516,6 @@ async fn shell_sandbox_denied_truncates_error_output() -> Result<()> { Wall time: [0-9]+(?:\.[0-9]+)? seconds Total output lines: \d+ Output: -Total output lines: \d+ failed in sandbox: .*?(?:Operation not permitted|Permission denied|Read-only file system).*? \[\.{3} omitted \d+ of \d+ lines \.{3}\] @@ -601,7 +600,6 @@ execution error: .*$"#; Wall time: [0-9]+(?:\.[0-9]+)? seconds Total output lines: \d+ Output: -Total output lines: \d+ execution error: .*$"#; let spawn_error_regex = Regex::new(spawn_error_pattern)?; From 90fe5e4a7e9bf504050b3ef4d149b34ec79dc3a3 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Sun, 5 Oct 2025 18:17:50 -0700 Subject: [PATCH 10/21] Add structured-output support (#4793) Add samples and docs. --- pnpm-lock.yaml | 20 +++++++ sdk/typescript/README.md | 36 +++++++++++- sdk/typescript/package.json | 4 +- sdk/typescript/samples/basic_streaming.ts | 8 +-- sdk/typescript/samples/helpers.ts | 6 ++ sdk/typescript/samples/structured_output.ts | 22 +++++++ .../samples/structured_output_zod.ts | 19 +++++++ sdk/typescript/src/exec.ts | 11 +++- sdk/typescript/src/index.ts | 1 + sdk/typescript/src/outputSchemaFile.ts | 41 +++++++++++++ sdk/typescript/src/thread.ts | 41 ++++++++----- sdk/typescript/src/turnOptions.ts | 4 ++ sdk/typescript/tests/responsesProxy.ts | 3 + sdk/typescript/tests/run.test.ts | 57 +++++++++++++++++++ sdk/typescript/tests/runStreamed.test.ts | 44 ++++++++++++++ 15 files changed, 291 insertions(+), 26 deletions(-) create mode 100644 sdk/typescript/samples/helpers.ts create mode 100755 sdk/typescript/samples/structured_output.ts create mode 100755 sdk/typescript/samples/structured_output_zod.ts create mode 100644 sdk/typescript/src/outputSchemaFile.ts create mode 100644 sdk/typescript/src/turnOptions.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 75db5f5971..2b0ee599be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -61,6 +61,12 @@ importers: typescript-eslint: specifier: ^8.45.0 version: 8.45.0(eslint@9.36.0)(typescript@5.9.2) + zod: + specifier: ^3.24.2 + version: 3.25.76 + zod-to-json-schema: + specifier: ^3.24.6 + version: 3.24.6(zod@3.25.76) packages: @@ -2175,6 +2181,14 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod-to-json-schema@3.24.6: + resolution: {integrity: sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==} + peerDependencies: + zod: ^3.24.1 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + snapshots: '@babel/code-frame@7.27.1': @@ -4492,3 +4506,9 @@ snapshots: yn@3.1.1: {} yocto-queue@0.1.0: {} + + zod-to-json-schema@3.24.6(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod@3.25.76: {} diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 35c0c8f485..16b179ef42 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -50,6 +50,41 @@ for await (const event of events) { } ``` +### Structured output + +Provide a JSON schema per turn to have Codex respond with structured JSON. Pass schemas as +plain JavaScript objects. + + +```typescript +const schema = { + type: "object", + properties: { + summary: { type: "string" }, + status: { type: "string", enum: ["ok", "action_required"] }, + }, + required: ["summary", "status"], + additionalProperties: false, +} as const; + +const turn = await thread.run("Summarize repository status", { outputSchema: schema }); +console.log(turn.finalResponse); +``` + +You can also create JSON schemas for Zod types using the `zod-to-json-schema` package and setting the `target` to `"openAi"`. + +```typescript +const schema = z.object({ + summary: z.string(), + status: z.enum(["ok", "action_required"]), +}); + +const turn = await thread.run("Summarize repository status", { + outputSchema: zodToJsonSchema(schema, { target: "openAi" }), +}); +console.log(turn.finalResponse); +``` + ### Resuming an existing thread Threads are persisted in `~/.codex/sessions`. If you lose the in-memory `Thread` object, reconstruct it with `resumeThread()` and keep going. @@ -70,4 +105,3 @@ const thread = codex.startThread({ skipGitRepoCheck: true, }); ``` - diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 835da304b0..d03bf34ccd 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -58,6 +58,8 @@ "ts-node": "^10.9.2", "tsup": "^8.5.0", "typescript": "^5.9.2", - "typescript-eslint": "^8.45.0" + "typescript-eslint": "^8.45.0", + "zod": "^3.24.2", + "zod-to-json-schema": "^3.24.6" } } diff --git a/sdk/typescript/samples/basic_streaming.ts b/sdk/typescript/samples/basic_streaming.ts index e78a0d41ac..f9ccbe40d1 100755 --- a/sdk/typescript/samples/basic_streaming.ts +++ b/sdk/typescript/samples/basic_streaming.ts @@ -5,13 +5,9 @@ import { stdin as input, stdout as output } from "node:process"; import { Codex } from "@openai/codex-sdk"; import type { ThreadEvent, ThreadItem } from "@openai/codex-sdk"; -import path from "node:path"; +import { codexPathOverride } from "./helpers.ts"; -const codexPathOverride = - process.env.CODEX_EXECUTABLE ?? - path.join(process.cwd(), "..", "..", "codex-rs", "target", "debug", "codex"); - -const codex = new Codex({ codexPathOverride }); +const codex = new Codex({ codexPathOverride: codexPathOverride() }); const thread = codex.startThread(); const rl = createInterface({ input, output }); diff --git a/sdk/typescript/samples/helpers.ts b/sdk/typescript/samples/helpers.ts new file mode 100644 index 0000000000..c9d851c8ae --- /dev/null +++ b/sdk/typescript/samples/helpers.ts @@ -0,0 +1,6 @@ +import path from "node:path"; + +export function codexPathOverride() { + return process.env.CODEX_EXECUTABLE ?? + path.join(process.cwd(), "..", "..", "codex-rs", "target", "debug", "codex"); +} diff --git a/sdk/typescript/samples/structured_output.ts b/sdk/typescript/samples/structured_output.ts new file mode 100755 index 0000000000..60063c10fa --- /dev/null +++ b/sdk/typescript/samples/structured_output.ts @@ -0,0 +1,22 @@ +#!/usr/bin/env -S NODE_NO_WARNINGS=1 pnpm ts-node-esm --files + +import { Codex } from "@openai/codex-sdk"; + +import { codexPathOverride } from "./helpers.ts"; + +const codex = new Codex({ codexPathOverride: codexPathOverride() }); + +const thread = codex.startThread(); + +const schema = { + type: "object", + properties: { + summary: { type: "string" }, + status: { type: "string", enum: ["ok", "action_required"] }, + }, + required: ["summary", "status"], + additionalProperties: false, +} as const; + +const turn = await thread.run("Summarize repository status", { outputSchema: schema }); +console.log(turn.finalResponse); diff --git a/sdk/typescript/samples/structured_output_zod.ts b/sdk/typescript/samples/structured_output_zod.ts new file mode 100755 index 0000000000..917bc39114 --- /dev/null +++ b/sdk/typescript/samples/structured_output_zod.ts @@ -0,0 +1,19 @@ +#!/usr/bin/env -S NODE_NO_WARNINGS=1 pnpm ts-node-esm --files + +import { Codex } from "@openai/codex-sdk"; +import { codexPathOverride } from "./helpers.ts"; +import z from "zod"; +import zodToJsonSchema from "zod-to-json-schema"; + +const codex = new Codex({ codexPathOverride: codexPathOverride() }); +const thread = codex.startThread(); + +const schema = z.object({ + summary: z.string(), + status: z.enum(["ok", "action_required"]), +}); + +const turn = await thread.run("Summarize repository status", { + outputSchema: zodToJsonSchema(schema, { target: "openAi" }), +}); +console.log(turn.finalResponse); diff --git a/sdk/typescript/src/exec.ts b/sdk/typescript/src/exec.ts index a20d1c4db1..ac9e548b93 100644 --- a/sdk/typescript/src/exec.ts +++ b/sdk/typescript/src/exec.ts @@ -1,10 +1,9 @@ import { spawn } from "node:child_process"; - +import path from "node:path"; import readline from "node:readline"; +import { fileURLToPath } from "node:url"; import { SandboxMode } from "./threadOptions"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; export type CodexExecArgs = { input: string; @@ -20,6 +19,8 @@ export type CodexExecArgs = { workingDirectory?: string; // --skip-git-repo-check skipGitRepoCheck?: boolean; + // --output-schema + outputSchemaFile?: string; }; export class CodexExec { @@ -47,6 +48,10 @@ export class CodexExec { commandArgs.push("--skip-git-repo-check"); } + if (args.outputSchemaFile) { + commandArgs.push("--output-schema", args.outputSchemaFile); + } + if (args.threadId) { commandArgs.push("resume", args.threadId); } diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 738b1dc6bb..6e1ace9d14 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -31,3 +31,4 @@ export { Codex } from "./codex"; export type { CodexOptions } from "./codexOptions"; export type { ThreadOptions, ApprovalMode, SandboxMode } from "./threadOptions"; +export type { TurnOptions } from "./turnOptions"; diff --git a/sdk/typescript/src/outputSchemaFile.ts b/sdk/typescript/src/outputSchemaFile.ts new file mode 100644 index 0000000000..55d2c41526 --- /dev/null +++ b/sdk/typescript/src/outputSchemaFile.ts @@ -0,0 +1,41 @@ +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export type OutputSchemaFile = { + schemaPath?: string; + cleanup: () => Promise; +}; + +export async function createOutputSchemaFile(schema: unknown): Promise { + if (schema === undefined) { + return { cleanup: async () => {} }; + } + + if (!isJsonObject(schema)) { + throw new Error("outputSchema must be a plain JSON object"); + } + + const schemaDir = await fs.mkdtemp(path.join(os.tmpdir(), "codex-output-schema-")); + const schemaPath = path.join(schemaDir, "schema.json"); + const cleanup = async () => { + try { + await fs.rm(schemaDir, { recursive: true, force: true }); + } + catch { + // suppress + } + }; + + try { + await fs.writeFile(schemaPath, JSON.stringify(schema), "utf8"); + return { schemaPath, cleanup }; + } catch (error) { + await cleanup(); + throw error; + } +} + +function isJsonObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/sdk/typescript/src/thread.ts b/sdk/typescript/src/thread.ts index d4745d49d6..0caf52b486 100644 --- a/sdk/typescript/src/thread.ts +++ b/sdk/typescript/src/thread.ts @@ -3,6 +3,8 @@ import { ThreadEvent, ThreadError, Usage } from "./events"; import { CodexExec } from "./exec"; import { ThreadItem } from "./items"; import { ThreadOptions } from "./threadOptions"; +import { TurnOptions } from "./turnOptions"; +import { createOutputSchemaFile } from "./outputSchemaFile"; /** Completed turn. */ export type Turn = { @@ -51,11 +53,15 @@ export class Thread { } /** Provides the input to the agent and streams events as they are produced during the turn. */ - async runStreamed(input: string): Promise { - return { events: this.runStreamedInternal(input) }; + async runStreamed(input: string, turnOptions: TurnOptions = {}): Promise { + return { events: this.runStreamedInternal(input, turnOptions) }; } - private async *runStreamedInternal(input: string): AsyncGenerator { + private async *runStreamedInternal( + input: string, + turnOptions: TurnOptions = {}, + ): AsyncGenerator { + const { schemaPath, cleanup } = await createOutputSchemaFile(turnOptions.outputSchema); const options = this._threadOptions; const generator = this._exec.run({ input, @@ -66,24 +72,29 @@ export class Thread { sandboxMode: options?.sandboxMode, workingDirectory: options?.workingDirectory, skipGitRepoCheck: options?.skipGitRepoCheck, + outputSchemaFile: schemaPath, }); - for await (const item of generator) { - let parsed: ThreadEvent; - try { - parsed = JSON.parse(item) as ThreadEvent; - } catch (error) { - throw new Error(`Failed to parse item: ${item}`, { cause: error }); + try { + for await (const item of generator) { + let parsed: ThreadEvent; + try { + parsed = JSON.parse(item) as ThreadEvent; + } catch (error) { + throw new Error(`Failed to parse item: ${item}`, { cause: error }); + } + if (parsed.type === "thread.started") { + this._id = parsed.thread_id; + } + yield parsed; } - if (parsed.type === "thread.started") { - this._id = parsed.thread_id; - } - yield parsed; + } finally { + await cleanup(); } } /** Provides the input to the agent and returns the completed turn. */ - async run(input: string): Promise { - const generator = this.runStreamedInternal(input); + async run(input: string, turnOptions: TurnOptions = {}): Promise { + const generator = this.runStreamedInternal(input, turnOptions); const items: ThreadItem[] = []; let finalResponse: string = ""; let usage: Usage | null = null; diff --git a/sdk/typescript/src/turnOptions.ts b/sdk/typescript/src/turnOptions.ts new file mode 100644 index 0000000000..ed602f2f92 --- /dev/null +++ b/sdk/typescript/src/turnOptions.ts @@ -0,0 +1,4 @@ +export type TurnOptions = { + /** JSON schema describing the expected agent output. */ + outputSchema?: unknown; +}; diff --git a/sdk/typescript/tests/responsesProxy.ts b/sdk/typescript/tests/responsesProxy.ts index cf5e97fb19..9f0060d37f 100644 --- a/sdk/typescript/tests/responsesProxy.ts +++ b/sdk/typescript/tests/responsesProxy.ts @@ -46,6 +46,9 @@ export type ResponsesApiRequest = { role: string; content?: Array<{ type: string; text: string }>; }>; + text?: { + format?: Record; + }; }; export type RecordedRequest = { diff --git a/sdk/typescript/tests/run.test.ts b/sdk/typescript/tests/run.test.ts index d404761d8f..b711e5c506 100644 --- a/sdk/typescript/tests/run.test.ts +++ b/sdk/typescript/tests/run.test.ts @@ -222,6 +222,63 @@ describe("Codex", () => { await close(); } }); + + it("writes output schema to a temporary file and forwards it", async () => { + const { url, close, requests } = await startResponsesTestProxy({ + statusCode: 200, + responseBodies: [ + sse( + responseStarted("response_1"), + assistantMessage("Structured response", "item_1"), + responseCompleted("response_1"), + ), + ], + }); + + const { args: spawnArgs, restore } = codexExecSpy(); + + const schema = { + type: "object", + properties: { + answer: { type: "string" }, + }, + required: ["answer"], + additionalProperties: false, + } as const; + + try { + const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); + + const thread = client.startThread(); + await thread.run("structured", { outputSchema: schema }); + + expect(requests.length).toBeGreaterThanOrEqual(1); + const payload = requests[0]; + expect(payload).toBeDefined(); + const text = payload!.json.text; + expect(text).toBeDefined(); + expect(text?.format).toEqual({ + name: "codex_output_schema", + type: "json_schema", + strict: true, + schema, + }); + + const commandArgs = spawnArgs[0]; + expect(commandArgs).toBeDefined(); + const schemaFlagIndex = commandArgs!.indexOf("--output-schema"); + expect(schemaFlagIndex).toBeGreaterThan(-1); + const schemaPath = commandArgs![schemaFlagIndex + 1]; + expect(typeof schemaPath).toBe("string"); + if (typeof schemaPath !== "string") { + throw new Error("--output-schema flag missing path argument"); + } + expect(fs.existsSync(schemaPath)).toBe(false); + } finally { + restore(); + await close(); + } + }); it("runs in provided working directory", async () => { const { url, close } = await startResponsesTestProxy({ statusCode: 200, diff --git a/sdk/typescript/tests/runStreamed.test.ts b/sdk/typescript/tests/runStreamed.test.ts index f0c4d90b06..6cdf22fea5 100644 --- a/sdk/typescript/tests/runStreamed.test.ts +++ b/sdk/typescript/tests/runStreamed.test.ts @@ -157,6 +157,50 @@ describe("Codex", () => { await close(); } }); + + it("applies output schema turn options when streaming", async () => { + const { url, close, requests } = await startResponsesTestProxy({ + statusCode: 200, + responseBodies: [ + sse( + responseStarted("response_1"), + assistantMessage("Structured response", "item_1"), + responseCompleted("response_1"), + ), + ], + }); + + const schema = { + type: "object", + properties: { + answer: { type: "string" }, + }, + required: ["answer"], + additionalProperties: false, + } as const; + + try { + const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); + + const thread = client.startThread(); + const streamed = await thread.runStreamed("structured", { outputSchema: schema }); + await drainEvents(streamed.events); + + expect(requests.length).toBeGreaterThanOrEqual(1); + const payload = requests[0]; + expect(payload).toBeDefined(); + const text = payload!.json.text; + expect(text).toBeDefined(); + expect(text?.format).toEqual({ + name: "codex_output_schema", + type: "json_schema", + strict: true, + schema, + }); + } finally { + await close(); + } + }); }); async function drainEvents(events: AsyncGenerator): Promise { From 8cd882c4bd0221d0ba42a20faa7434863f1785bf Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Sun, 5 Oct 2025 18:21:29 -0700 Subject: [PATCH 11/21] Update README.md (#4794) # External (non-OpenAI) Pull Request Requirements Before opening this Pull Request, please read the dedicated "Contributing" markdown file or your PR may be closed: https://github.com/openai/codex/blob/main/docs/contributing.md If your PR conforms to our contribution guidelines, replace this text with a detailed and high quality description of your changes. --- sdk/typescript/README.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 16b179ef42..1aeffce673 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -52,9 +52,7 @@ for await (const event of events) { ### Structured output -Provide a JSON schema per turn to have Codex respond with structured JSON. Pass schemas as -plain JavaScript objects. - +The Codex agent can produce a JSON response that conforms to a specified schema. The schema can be provided for each turn as a plain JSON object. ```typescript const schema = { @@ -71,7 +69,7 @@ const turn = await thread.run("Summarize repository status", { outputSchema: sch console.log(turn.finalResponse); ``` -You can also create JSON schemas for Zod types using the `zod-to-json-schema` package and setting the `target` to `"openAi"`. +You can also create a JSON schema from a [Zod schema](https://github.com/colinhacks/zod) using the [`zod-to-json-schema`](https://www.npmjs.com/package/zod-to-json-schema) package and setting the `target` to `"openAi"`. ```typescript const schema = z.object({ From c264ae6021146d1dd1867e4fec9ebf1a5fe5d82b Mon Sep 17 00:00:00 2001 From: ae Date: Sun, 5 Oct 2025 19:44:26 -0700 Subject: [PATCH 12/21] feat: tweak windows wsl copy (#4795) Tweaked the WSL dialogue and the installation instructions. --- codex-rs/tui/src/onboarding/windows.rs | 44 +++++++++++++------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/codex-rs/tui/src/onboarding/windows.rs b/codex-rs/tui/src/onboarding/windows.rs index a529428a11..4e717c5dd6 100644 --- a/codex-rs/tui/src/onboarding/windows.rs +++ b/codex-rs/tui/src/onboarding/windows.rs @@ -19,14 +19,15 @@ use crate::onboarding::onboarding_screen::StepStateProvider; use super::onboarding_screen::StepState; -pub(crate) const WSL_INSTRUCTIONS: &str = r"Windows Subsystem for Linux (WSL2) is required to run Codex. +pub(crate) const WSL_INSTRUCTIONS: &str = r"Install WSL2 by opening PowerShell as Administrator and running: -To install WSL2: - 1. Open PowerShell as Administrator and run: wsl --install - 2. Restart your machine if prompted. - 3. Launch the Ubuntu shortcut from the Start menu to complete setup. + wsl --install -After installation, reopen Codex from a WSL shell."; +After installation, run Codex from inside of WSL2 with: + + wsl -e codex + +More info: https://developers.openai.com/codex/windows"; pub(crate) struct WindowsSetupWidget { pub codex_home: PathBuf, @@ -47,7 +48,7 @@ impl WindowsSetupWidget { Self { codex_home, selection: None, - highlighted: WindowsSetupSelection::Continue, + highlighted: WindowsSetupSelection::Install, error: None, exit_requested: false, } @@ -83,11 +84,10 @@ impl WindowsSetupWidget { impl WidgetRef for &WindowsSetupWidget { fn render_ref(&self, area: Rect, buf: &mut Buffer) { let mut lines: Vec = vec![ - Line::from(vec!["> ".into(), "Codex Windows Support".bold()]), - Line::from(""), - Line::from( - " Codex support for Windows is in progress. Full support for Codex on Windows requires Windows Subsystem for Linux (WSL2).", - ), + Line::from(vec![ + "> ".into(), + "For best performance, run Codex in Windows Subsystem for Linux (WSL2)".bold(), + ]), Line::from(""), ]; @@ -102,13 +102,13 @@ impl WidgetRef for &WindowsSetupWidget { lines.push(create_option( 0, - WindowsSetupSelection::Continue, - "Continue anyway", + WindowsSetupSelection::Install, + "Exit and install WSL2", )); lines.push(create_option( 1, - WindowsSetupSelection::Install, - "Exit and install Windows Subsystem for Linux (WSL2)", + WindowsSetupSelection::Continue, + "Continue anyway", )); lines.push("".into()); @@ -133,16 +133,16 @@ impl KeyboardHandler for WindowsSetupWidget { match key_event.code { KeyCode::Up | KeyCode::Char('k') => { - self.highlighted = WindowsSetupSelection::Continue; - } - KeyCode::Down | KeyCode::Char('j') => { self.highlighted = WindowsSetupSelection::Install; } - KeyCode::Char('1') => self.handle_continue(), - KeyCode::Char('2') => self.handle_install(), + KeyCode::Down | KeyCode::Char('j') => { + self.highlighted = WindowsSetupSelection::Continue; + } + KeyCode::Char('1') => self.handle_install(), + KeyCode::Char('2') => self.handle_continue(), KeyCode::Enter => match self.highlighted { - WindowsSetupSelection::Continue => self.handle_continue(), WindowsSetupSelection::Install => self.handle_install(), + WindowsSetupSelection::Continue => self.handle_continue(), }, _ => {} } From d3e1beb26c326dcb078c33c5eb43c1cbbc9b61c4 Mon Sep 17 00:00:00 2001 From: Ed Bayes Date: Mon, 6 Oct 2025 09:56:27 +0530 Subject: [PATCH 13/21] add pulsing dot loading state (#4736) ## Description Changes default CLI spinner to pulsing dot https://github.com/user-attachments/assets/b81225d6-6655-4ead-8cb1-d6568a603d5b ## Tests Passes CI --------- Co-authored-by: Fouad Matin --- codex-rs/Cargo.lock | 11 ---- codex-rs/cloud-tasks/Cargo.toml | 34 ++++++------ codex-rs/cloud-tasks/src/app.rs | 7 +-- codex-rs/cloud-tasks/src/lib.rs | 10 +++- codex-rs/cloud-tasks/src/ui.rs | 55 +++++++++++-------- ...dget__tests__exploring_step1_start_ls.snap | 2 +- ..._tests__exploring_step3_start_cat_foo.snap | 2 +- codex-rs/tui/src/exec_cell/render.rs | 10 ++-- ..._tests__active_mcp_tool_call_snapshot.snap | 2 +- codex-rs/tui/src/status_indicator_widget.rs | 21 +++++-- 10 files changed, 84 insertions(+), 70 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 0a650511e3..350773a3c5 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -988,7 +988,6 @@ dependencies = [ "reqwest", "serde", "serde_json", - "throbber-widgets-tui", "tokio", "tokio-stream", "tracing", @@ -5843,16 +5842,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "throbber-widgets-tui" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d36b5738d666a2b4c91b7c24998a8588db724b3107258343ebf8824bf55b06d" -dependencies = [ - "rand 0.8.5", - "ratatui", -] - [[package]] name = "tiff" version = "0.10.3" diff --git a/codex-rs/cloud-tasks/Cargo.toml b/codex-rs/cloud-tasks/Cargo.toml index d0cee3ff1a..13d7252760 100644 --- a/codex-rs/cloud-tasks/Cargo.toml +++ b/codex-rs/cloud-tasks/Cargo.toml @@ -1,7 +1,7 @@ [package] +edition = "2024" name = "codex-cloud-tasks" version = { workspace = true } -edition = "2024" [lib] name = "codex_cloud_tasks" @@ -12,25 +12,27 @@ workspace = true [dependencies] anyhow = "1" -clap = { version = "4", features = ["derive"] } -codex-common = { path = "../common", features = ["cli"] } -tokio = { version = "1", features = ["macros", "rt-multi-thread"] } -tracing = { version = "0.1.41", features = ["log"] } -tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } -codex-cloud-tasks-client = { path = "../cloud-tasks-client", features = ["mock", "online"] } -ratatui = { version = "0.29.0" } -crossterm = { version = "0.28.1", features = ["event-stream"] } -tokio-stream = "0.1.17" -chrono = { version = "0.4", features = ["serde"] } -codex-login = { path = "../login" } -codex-core = { path = "../core" } -throbber-widgets-tui = "0.8.0" base64 = "0.22" -serde_json = "1" +chrono = { version = "0.4", features = ["serde"] } +clap = { version = "4", features = ["derive"] } +codex-cloud-tasks-client = { path = "../cloud-tasks-client", features = [ + "mock", + "online", +] } +codex-common = { path = "../common", features = ["cli"] } +codex-core = { path = "../core" } +codex-login = { path = "../login" } +codex-tui = { path = "../tui" } +crossterm = { version = "0.28.1", features = ["event-stream"] } +ratatui = { version = "0.29.0" } reqwest = { version = "0.12", features = ["json"] } serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +tokio-stream = "0.1.17" +tracing = { version = "0.1.41", features = ["log"] } +tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } unicode-width = "0.1" -codex-tui = { path = "../tui" } [dev-dependencies] async-trait = "0.1" diff --git a/codex-rs/cloud-tasks/src/app.rs b/codex-rs/cloud-tasks/src/app.rs index adf17419ee..612c5f6be4 100644 --- a/codex-rs/cloud-tasks/src/app.rs +++ b/codex-rs/cloud-tasks/src/app.rs @@ -1,4 +1,5 @@ use std::time::Duration; +use std::time::Instant; // Environment filter data models for the TUI #[derive(Clone, Debug, Default)] @@ -42,15 +43,13 @@ use crate::scrollable_diff::ScrollableDiff; use codex_cloud_tasks_client::CloudBackend; use codex_cloud_tasks_client::TaskId; use codex_cloud_tasks_client::TaskSummary; -use throbber_widgets_tui::ThrobberState; - #[derive(Default)] pub struct App { pub tasks: Vec, pub selected: usize, pub status: String, pub diff_overlay: Option, - pub throbber: ThrobberState, + pub spinner_start: Option, pub refresh_inflight: bool, pub details_inflight: bool, // Environment filter state @@ -82,7 +81,7 @@ impl App { selected: 0, status: "Press r to refresh".to_string(), diff_overlay: None, - throbber: ThrobberState::default(), + spinner_start: None, refresh_inflight: false, details_inflight: false, env_filter: None, diff --git a/codex-rs/cloud-tasks/src/lib.rs b/codex-rs/cloud-tasks/src/lib.rs index da2d4eb908..69490e1c9a 100644 --- a/codex-rs/cloud-tasks/src/lib.rs +++ b/codex-rs/cloud-tasks/src/lib.rs @@ -400,16 +400,20 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option) -> a let _ = frame_tx.send(Instant::now() + codex_tui::ComposerInput::recommended_flush_delay()); } } - // Advance throbber only while loading. + // Keep spinner pulsing only while loading. if app.refresh_inflight || app.details_inflight || app.env_loading || app.apply_preflight_inflight || app.apply_inflight { - app.throbber.calc_next(); + if app.spinner_start.is_none() { + app.spinner_start = Some(Instant::now()); + } needs_redraw = true; - let _ = frame_tx.send(Instant::now() + Duration::from_millis(100)); + let _ = frame_tx.send(Instant::now() + Duration::from_millis(600)); + } else { + app.spinner_start = None; } render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; } diff --git a/codex-rs/cloud-tasks/src/ui.rs b/codex-rs/cloud-tasks/src/ui.rs index fe91b0ce9b..81353a032b 100644 --- a/codex-rs/cloud-tasks/src/ui.rs +++ b/codex-rs/cloud-tasks/src/ui.rs @@ -16,6 +16,7 @@ use ratatui::widgets::ListState; use ratatui::widgets::Padding; use ratatui::widgets::Paragraph; use std::sync::OnceLock; +use std::time::Instant; use crate::app::App; use crate::app::AttemptView; @@ -229,7 +230,7 @@ fn draw_list(frame: &mut Frame, area: Rect, app: &mut App) { // In-box spinner during initial/refresh loads if app.refresh_inflight { - draw_centered_spinner(frame, inner, &mut app.throbber, "Loading tasks…"); + draw_centered_spinner(frame, inner, &mut app.spinner_start, "Loading tasks…"); } } @@ -291,7 +292,7 @@ fn draw_footer(frame: &mut Frame, area: Rect, app: &mut App) { || app.apply_preflight_inflight || app.apply_inflight { - draw_inline_spinner(frame, top[1], &mut app.throbber, "Loading…"); + draw_inline_spinner(frame, top[1], &mut app.spinner_start, "Loading…"); } else { frame.render_widget(Clear, top[1]); } @@ -449,7 +450,12 @@ fn draw_diff_overlay(frame: &mut Frame, area: Rect, app: &mut App) { .map(|o| o.sd.wrapped_lines().is_empty()) .unwrap_or(true); if app.details_inflight && raw_empty { - draw_centered_spinner(frame, content_area, &mut app.throbber, "Loading details…"); + draw_centered_spinner( + frame, + content_area, + &mut app.spinner_start, + "Loading details…", + ); } else { let scroll = app .diff_overlay @@ -494,11 +500,11 @@ pub fn draw_apply_modal(frame: &mut Frame, area: Rect, app: &mut App) { frame.render_widget(header, rows[0]); // Body: spinner while preflight/apply runs; otherwise show result message and path lists if app.apply_preflight_inflight { - draw_centered_spinner(frame, rows[1], &mut app.throbber, "Checking…"); + draw_centered_spinner(frame, rows[1], &mut app.spinner_start, "Checking…"); } else if app.apply_inflight { - draw_centered_spinner(frame, rows[1], &mut app.throbber, "Applying…"); + draw_centered_spinner(frame, rows[1], &mut app.spinner_start, "Applying…"); } else if m.result_message.is_none() { - draw_centered_spinner(frame, rows[1], &mut app.throbber, "Loading…"); + draw_centered_spinner(frame, rows[1], &mut app.spinner_start, "Loading…"); } else if let Some(msg) = &m.result_message { let mut body_lines: Vec = Vec::new(); let first = match m.result_level { @@ -859,29 +865,29 @@ fn format_relative_time(ts: chrono::DateTime) -> String { fn draw_inline_spinner( frame: &mut Frame, area: Rect, - state: &mut throbber_widgets_tui::ThrobberState, + spinner_start: &mut Option, label: &str, ) { - use ratatui::style::Style; - use throbber_widgets_tui::BRAILLE_EIGHT; - use throbber_widgets_tui::Throbber; - use throbber_widgets_tui::WhichUse; - let w = Throbber::default() - .label(label) - .style(Style::default().cyan()) - .throbber_style(Style::default().magenta().bold()) - .throbber_set(BRAILLE_EIGHT) - .use_type(WhichUse::Spin); - frame.render_stateful_widget(w, area, state); + use ratatui::widgets::Paragraph; + let start = spinner_start.get_or_insert_with(Instant::now); + let blink_on = (start.elapsed().as_millis() / 600).is_multiple_of(2); + let dot = if blink_on { + "• ".into() + } else { + "◦ ".dim() + }; + let label = label.cyan(); + let line = Line::from(vec![dot, label]); + frame.render_widget(Paragraph::new(line), area); } fn draw_centered_spinner( frame: &mut Frame, area: Rect, - state: &mut throbber_widgets_tui::ThrobberState, + spinner_start: &mut Option, label: &str, ) { - // Center a 1xN throbber within the given rect + // Center a 1xN spinner within the given rect let rows = Layout::default() .direction(Direction::Vertical) .constraints([ @@ -898,7 +904,7 @@ fn draw_centered_spinner( Constraint::Percentage(50), ]) .split(rows[1]); - draw_inline_spinner(frame, cols[1], state, label); + draw_inline_spinner(frame, cols[1], spinner_start, label); } // Styling helpers for diff rendering live inline where used. @@ -918,7 +924,12 @@ pub fn draw_env_modal(frame: &mut Frame, area: Rect, app: &mut App) { let content = overlay_content(inner); if app.env_loading { - draw_centered_spinner(frame, content, &mut app.throbber, "Loading environments…"); + draw_centered_spinner( + frame, + content, + &mut app.spinner_start, + "Loading environments…", + ); return; } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step1_start_ls.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step1_start_ls.snap index 3b33800642..588a9503eb 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step1_start_ls.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step1_start_ls.snap @@ -2,5 +2,5 @@ source: tui/src/chatwidget/tests.rs expression: blob1 --- -⠋ Exploring +• Exploring └ List ls -la diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step3_start_cat_foo.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step3_start_cat_foo.snap index ddcd484b0d..2ce4170929 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step3_start_cat_foo.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step3_start_cat_foo.snap @@ -2,6 +2,6 @@ source: tui/src/chatwidget/tests.rs expression: blob3 --- -⠋ Exploring +• Exploring └ List ls -la Read foo.txt diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs index 19232c86dd..9c1231f0cf 100644 --- a/codex-rs/tui/src/exec_cell/render.rs +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -116,12 +116,10 @@ pub(crate) fn output_lines( } pub(crate) fn spinner(start_time: Option) -> Span<'static> { - const FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; - let idx = start_time - .map(|st| ((st.elapsed().as_millis() / 100) as usize) % FRAMES.len()) - .unwrap_or(0); - let ch = FRAMES[idx]; - ch.to_string().into() + let blink_on = start_time + .map(|st| ((st.elapsed().as_millis() / 600) % 2) == 0) + .unwrap_or(false); + if blink_on { "•".into() } else { "◦".dim() } } impl HistoryCell for ExecCell { diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__active_mcp_tool_call_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__active_mcp_tool_call_snapshot.snap index 2c1067b2e1..7360fd138f 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__active_mcp_tool_call_snapshot.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__active_mcp_tool_call_snapshot.snap @@ -3,4 +3,4 @@ source: tui/src/history_cell.rs assertion_line: 1740 expression: rendered --- -⠋ Calling search.find_docs({"query":"ratatui styling","limit":3}) +• Calling search.find_docs({"query":"ratatui styling","limit":3}) diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index 2660aa1b2a..e60204cdb8 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -134,12 +134,16 @@ impl StatusIndicatorWidget { self.frame_requester.schedule_frame(); } - fn elapsed_seconds_at(&self, now: Instant) -> u64 { + fn elapsed_duration_at(&self, now: Instant) -> Duration { let mut elapsed = self.elapsed_running; if !self.is_paused { elapsed += now.saturating_duration_since(self.last_resume_at); } - elapsed.as_secs() + elapsed + } + + fn elapsed_seconds_at(&self, now: Instant) -> u64 { + self.elapsed_duration_at(now).as_secs() } pub fn elapsed_seconds(&self) -> u64 { @@ -156,11 +160,18 @@ impl WidgetRef for StatusIndicatorWidget { // Schedule next animation frame. self.frame_requester .schedule_frame_in(Duration::from_millis(32)); - let elapsed = self.elapsed_seconds(); - let pretty_elapsed = fmt_elapsed_compact(elapsed); + let now = Instant::now(); + let elapsed_duration = self.elapsed_duration_at(now); + let pretty_elapsed = fmt_elapsed_compact(elapsed_duration.as_secs()); + let blink_on = (elapsed_duration.as_millis() / 600).is_multiple_of(2); // Plain rendering: no borders or padding so the live cell is visually indistinguishable from terminal scrollback. - let mut spans = vec!["• ".dim()]; + let mut spans = Vec::with_capacity(5); + if blink_on { + spans.push("• ".into()); + } else { + spans.push("◦ ".dim()); + } spans.extend(shimmer_spans(&self.header)); spans.extend(vec![ " ".into(), From 36f1cca1b131e61592dc5a4ca3e5351bcf8d23e0 Mon Sep 17 00:00:00 2001 From: Fouad Matin <169186268+fouad-openai@users.noreply.github.com> Date: Sun, 5 Oct 2025 22:06:21 -0700 Subject: [PATCH 14/21] fix: windows instructions (#4807) link to docs --- codex-rs/tui/src/onboarding/windows.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/codex-rs/tui/src/onboarding/windows.rs b/codex-rs/tui/src/onboarding/windows.rs index 4e717c5dd6..a779fbc874 100644 --- a/codex-rs/tui/src/onboarding/windows.rs +++ b/codex-rs/tui/src/onboarding/windows.rs @@ -23,11 +23,9 @@ pub(crate) const WSL_INSTRUCTIONS: &str = r"Install WSL2 by opening PowerShell a wsl --install -After installation, run Codex from inside of WSL2 with: +Instructions for how to install and run Codex in WSL: - wsl -e codex - -More info: https://developers.openai.com/codex/windows"; + https://developers.openai.com/codex/windows"; pub(crate) struct WindowsSetupWidget { pub codex_home: PathBuf, From 721003c552747249bad413763f436c7f4b67e41d Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Mon, 6 Oct 2025 08:43:50 -0700 Subject: [PATCH 15/21] [MCP] Improve docs (#4811) Updated, expanded on, clarified, and deduplicated some MCP docs --- README.md | 2 +- codex-rs/README.md | 10 +++++-- codex-rs/docs/codex_mcp_interface.md | 4 +-- docs/advanced.md | 10 +------ docs/config.md | 44 +++++++++++++++++++++++----- 5 files changed, 48 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index f17f605e18..c9cc4e1fd4 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ You can also use Codex with an API key, but this requires [additional setup](./d ### Model Context Protocol (MCP) -Codex CLI supports [MCP servers](./docs/advanced.md#model-context-protocol-mcp). Enable by adding an `mcp_servers` section to your `~/.codex/config.toml`. +Codex can access MCP servers. To configure them, refer to the [config docs](./docs/config.md#mcp_servers). ### Configuration diff --git a/codex-rs/README.md b/codex-rs/README.md index f5a5b838fc..a05a8d48b1 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -23,9 +23,15 @@ Codex supports a rich set of configuration options. Note that the Rust CLI uses ### Model Context Protocol Support -Codex CLI functions as an MCP client that can connect to MCP servers on startup. See the [`mcp_servers`](../docs/config.md#mcp_servers) section in the configuration documentation for details. +#### MCP client -It is still experimental, but you can also launch Codex as an MCP _server_ by running `codex mcp-server`. Use the [`@modelcontextprotocol/inspector`](https://github.com/modelcontextprotocol/inspector) to try it out: +Codex CLI functions as an MCP client that allows the Codex CLI and IDE extension to connect to MCP servers on startup. See the [`configuration documentation`](../docs/config.md#mcp_servers) for details. + +#### MCP server (experimental) + +Codex can be launched as an MCP _server_ by running `codex mcp-server`. This allows _other_ MCP clients to use Codex as a tool for another agent. + +Use the [`@modelcontextprotocol/inspector`](https://github.com/modelcontextprotocol/inspector) to try it out: ```shell npx @modelcontextprotocol/inspector codex mcp-server diff --git a/codex-rs/docs/codex_mcp_interface.md b/codex-rs/docs/codex_mcp_interface.md index 1291d3ee9f..eb0442feb3 100644 --- a/codex-rs/docs/codex_mcp_interface.md +++ b/codex-rs/docs/codex_mcp_interface.md @@ -1,6 +1,6 @@ -# Codex MCP Interface [experimental] +# Codex MCP Server Interface [experimental] -This document describes Codex’s experimental MCP interface: a JSON‑RPC API that runs over the Model Context Protocol (MCP) transport to control a local Codex engine. +This document describes Codex’s experimental MCP server interface: a JSON‑RPC API that runs over the Model Context Protocol (MCP) transport to control a local Codex engine. - Status: experimental and subject to change without notice - Server binary: `codex mcp-server` (or `codex-mcp-server`) diff --git a/docs/advanced.md b/docs/advanced.md index d5012c2875..7c986f583a 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -16,15 +16,7 @@ See the Rust documentation on [`RUST_LOG`](https://docs.rs/env_logger/latest/env ## Model Context Protocol (MCP) -The Codex CLI can be configured to leverage MCP servers by defining an [`mcp_servers`](./config.md#mcp_servers) section in `~/.codex/config.toml`. It is intended to mirror how tools such as Claude and Cursor define `mcpServers` in their respective JSON config files, though the Codex format is slightly different since it uses TOML rather than JSON, e.g.: - -```toml -# IMPORTANT: the top-level key is `mcp_servers` rather than `mcpServers`. -[mcp_servers.server-name] -command = "npx" -args = ["-y", "mcp-server"] -env = { "API_KEY" = "value" } -``` +The Codex CLI and IDE extension is a MCP client which means that it can be configured to connect to MCP servers. For more information, refer to the [`config docs`](./config.md#connecting-to-mcp-servers). ## Using Codex as an MCP Server diff --git a/docs/config.md b/docs/config.md index f079adda6d..aa45acc8dd 100644 --- a/docs/config.md +++ b/docs/config.md @@ -335,18 +335,20 @@ Codex provides three main Approval Presets: You can further customize how Codex runs at the command line using the `--ask-for-approval` and `--sandbox` options. -## MCP Servers +## Connecting to MCP servers -You can configure Codex to use [MCP servers](https://modelcontextprotocol.io/about) to give Codex access to external applications, resources, or services such as [Playwright](https://github.com/microsoft/playwright-mcp), [Figma](https://www.figma.com/blog/design-context-everywhere-you-build/), [documentation](https://context7.com/), and [more](https://github.com/mcp?utm_source=blog-source&utm_campaign=mcp-registry-server-launch-2025). +You can configure Codex to use [MCP servers](https://modelcontextprotocol.io/about) to give Codex access to external applications, resources, or services. -### Server transport configuration +### Server configuration #### STDIO +[STDIO servers](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio) are MCP servers that you can launch directly via commands on your computer. + ```toml # The top-level table name must be `mcp_servers` # The sub-table name (`server-name` in this example) can be anything you would like. -[mcp_servers.server-name] +[mcp_servers.server_name] command = "npx" # Optional args = ["-y", "mcp-server"] @@ -354,21 +356,26 @@ args = ["-y", "mcp-server"] # A default whitelist of env vars will be propagated to the MCP server. # https://github.com/openai/codex/blob/main/codex-rs/rmcp-client/src/utils.rs#L82 env = { "API_KEY" = "value" } +# or +[mcp_servers.server_name.env] +API_KEY = "value" ``` #### Streamable HTTP +[Streamable HTTP servers](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http) enable Codex to talk to resources that are accessed via a http url (either on localhost or another domain). + ```toml # Streamable HTTP requires the experimental rmcp client experimental_use_rmcp_client = true [mcp_servers.figma] -url = "http://127.0.0.1:3845/mcp" +url = "https://mcp.linear.app/mcp" # Optional bearer token to be passed into an `Authorization: Bearer ` header -# Use this with caution because the token is in plaintext. +# Use this with caution because the token is in plaintext and can be read by Codex itself. bearer_token = "" ``` -Refer to the MCP CLI commands for oauth login +For oauth login, you must enable `experimental_use_rmcp_client = true` and then run `codex mcp login server_name` ### Other configuration options @@ -381,17 +388,25 @@ tool_timeout_sec = 30 ### Experimental RMCP client -Codex is transitioning to the [official Rust MCP SDK](https://github.com/modelcontextprotocol/rust-sdk) and new functionality such as streamable http servers will only work with the new client. +Codex is transitioning to the [official Rust MCP SDK](https://github.com/modelcontextprotocol/rust-sdk). + +The flag enabled OAuth support for streamable HTTP servers and uses a new STDIO client implementation. Please try and report issues with the new client. To enable it, add this to the top level of your `config.toml` ```toml experimental_use_rmcp_client = true + +[mcp_servers.server_name] +… ``` ### MCP CLI commands ```shell +# List all available commands +codex mcp --help + # Add a server (env can be repeated; `--` separates the launcher command) codex mcp add docs -- docs-server --port 4000 @@ -413,6 +428,19 @@ codex mcp login SERVER_NAME codex mcp logout SERVER_NAME ``` +## Examples of useful MCPs + +There is an ever growing list of useful MCP servers that can be helpful while you are working with Codex. + +Some of the most common MCPs we've seen are: + +- [Context7](https://github.com/upstash/context7) — connect to a wide range of up-to-date developer documentation +- Figma [Local](https://developers.figma.com/docs/figma-mcp-server/local-server-installation/) and [Remote](https://developers.figma.com/docs/figma-mcp-server/remote-server-installation/) - access to your Figma designs +- [Playwright](https://www.npmjs.com/package/@playwright/mcp) - control and inspect a browser using Playwright +- [Chrome Developer Tools](https://github.com/ChromeDevTools/chrome-devtools-mcp/) — control and inspect a Chrome browser +- [Sentry](https://docs.sentry.io/product/sentry-mcp/#codex) — access to your Sentry logs +- [GitHub](https://github.com/github/github-mcp-server) — Control over your GitHub account beyond what git allows (like controlling PRs, issues, etc.) + ## shell_environment_policy Codex spawns subprocesses (e.g. when executing a `local_shell` tool-call suggested by the assistant). By default it now passes **your full environment** to those subprocesses. You can tune this behavior via the **`shell_environment_policy`** block in `config.toml`: From 661663c98a43178bc54fc4e2923c1a7089a90e2c Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 6 Oct 2025 10:07:52 -0700 Subject: [PATCH 16/21] Fix event names in exec docs. (#4833) Fixes: https://github.com/openai/codex/issues --- docs/exec.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/exec.md b/docs/exec.md index a0a7caf9de..e0f695cd4c 100644 --- a/docs/exec.md +++ b/docs/exec.md @@ -30,25 +30,25 @@ Supported event types: Supported item types: -- `assistant_message` - assistant message. +- `agent_message` - assistant message. - `reasoning` - a summary of the assistant's thinking. - `command_execution` - assistant executing a command. - `file_change` - assistant making file changes. - `mcp_tool_call` - assistant calling an MCP tool. - `web_search` - assistant performing a web search. -Typically, an `assistant_message` is added at the end of the turn. +Typically, an `agent_message` is added at the end of the turn. Sample output: ```jsonl {"type":"thread.started","thread_id":"0199a213-81c0-7800-8aa1-bbab2a035a53"} {"type":"turn.started"} -{"type":"item.completed","item":{"id":"item_0","item_type":"reasoning","text":"**Searching for README files**"}} -{"type":"item.started","item":{"id":"item_1","item_type":"command_execution","command":"bash -lc ls","aggregated_output":"","status":"in_progress"}} -{"type":"item.completed","item":{"id":"item_1","item_type":"command_execution","command":"bash -lc ls","aggregated_output":"2025-09-11\nAGENTS.md\nCHANGELOG.md\ncliff.toml\ncodex-cli\ncodex-rs\ndocs\nexamples\nflake.lock\nflake.nix\nLICENSE\nnode_modules\nNOTICE\npackage.json\npnpm-lock.yaml\npnpm-workspace.yaml\nPNPM.md\nREADME.md\nscripts\nsdk\ntmp\n","exit_code":0,"status":"completed"}} -{"type":"item.completed","item":{"id":"item_2","item_type":"reasoning","text":"**Checking repository root for README**"}} -{"type":"item.completed","item":{"id":"item_3","item_type":"assistant_message","text":"Yep — there’s a `README.md` in the repository root."}} +{"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"**Searching for README files**"}} +{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"bash -lc ls","aggregated_output":"","status":"in_progress"}} +{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"bash -lc ls","aggregated_output":"2025-09-11\nAGENTS.md\nCHANGELOG.md\ncliff.toml\ncodex-cli\ncodex-rs\ndocs\nexamples\nflake.lock\nflake.nix\nLICENSE\nnode_modules\nNOTICE\npackage.json\npnpm-lock.yaml\npnpm-workspace.yaml\nPNPM.md\nREADME.md\nscripts\nsdk\ntmp\n","exit_code":0,"status":"completed"}} +{"type":"item.completed","item":{"id":"item_2","type":"reasoning","text":"**Checking repository root for README**"}} +{"type":"item.completed","item":{"id":"item_3","type":"agent_message","text":"Yep — there’s a `README.md` in the repository root."}} {"type":"turn.completed","usage":{"input_tokens":24763,"cached_input_tokens":24448,"output_tokens":122}} ``` From 7e3a272b29559faabd689bc00a1b97045c745cd5 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 6 Oct 2025 10:39:26 -0700 Subject: [PATCH 17/21] Add a longer message to issue deduplicator and some logs (#4836) Logs are to diagnose why we're not filtering correctly. --- .github/workflows/issue-deduplicator.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/issue-deduplicator.yml b/.github/workflows/issue-deduplicator.yml index eed4d46167..2bac731e61 100644 --- a/.github/workflows/issue-deduplicator.yml +++ b/.github/workflows/issue-deduplicator.yml @@ -105,6 +105,10 @@ jobs: const issues = Array.isArray(parsed?.issues) ? parsed.issues : []; const currentIssueNumber = String(context.payload.issue.number); + + console.log(`Current issue number: ${currentIssueNumber}`); + console.log(issues); + const filteredIssues = issues.filter((value) => String(value) !== currentIssueNumber); if (filteredIssues.length === 0) { @@ -113,7 +117,8 @@ jobs: } const lines = [ - 'Potential duplicates detected:', + 'Potential duplicates detected. Please review them and close your issue if it is a duplicate.', + '', ...filteredIssues.map((value) => `- #${String(value)}`), '', '*Powered by [Codex Action](https://github.com/openai/codex-action)*']; From d73055c5b15f820590ccd7846c0ad9cc897e52dc Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Mon, 6 Oct 2025 14:41:16 -0700 Subject: [PATCH 18/21] [MCP] Fix the bearer token authorization header (#4846) `http_config.auth_header` automatically added `Bearer `. By adding it ourselves, we were sending `Bearer Bearer `. I confirmed that the GitHub MCP initialization 400s before and works now. I also optimized the oauth flow to not check the keyring if you explicitly pass in a bearer token. --- codex-rs/rmcp-client/src/rmcp_client.rs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index d19bf8fe72..676af9a5dd 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -120,14 +120,17 @@ impl RmcpClient { url: &str, bearer_token: Option, ) -> Result { - let initial_tokens = match load_oauth_tokens(server_name, url) { - Ok(tokens) => tokens, - Err(err) => { - warn!("failed to read tokens for server `{server_name}`: {err}"); - None - } + let initial_oauth_tokens = match bearer_token { + Some(_) => None, + None => match load_oauth_tokens(server_name, url) { + Ok(tokens) => tokens, + Err(err) => { + warn!("failed to read tokens for server `{server_name}`: {err}"); + None + } + }, }; - let transport = if let Some(initial_tokens) = initial_tokens.clone() { + let transport = if let Some(initial_tokens) = initial_oauth_tokens.clone() { let (transport, oauth_persistor) = create_oauth_transport_and_runtime(server_name, url, initial_tokens).await?; PendingTransport::StreamableHttpWithOAuth { @@ -137,7 +140,7 @@ impl RmcpClient { } else { let mut http_config = StreamableHttpClientTransportConfig::with_uri(url.to_string()); if let Some(bearer_token) = bearer_token { - http_config = http_config.auth_header(format!("Bearer {bearer_token}")); + http_config = http_config.auth_header(bearer_token); } let transport = StreamableHttpClientTransport::from_config(http_config); From 5833508a17c0738f9a14f45585a1d3eed0eade4d Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Mon, 6 Oct 2025 16:07:22 -0700 Subject: [PATCH 19/21] print codex resume note when quitting after codex resume (#4695) when exiting a session that was started with `codex resume`, the note about how to resume again wasn't being printed. thanks @aibrahim-oai for pointing out this issue! --- codex-rs/cli/src/main.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 04a7ff88f3..7e0036a57c 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -294,7 +294,8 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() last, config_overrides, ); - codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?; + let exit_info = codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?; + print_exit_messages(exit_info); } Some(Subcommand::Login(mut login_cli)) => { prepend_config_flags( From b09f62a1c365dc75b6d663523bd89f54f24f2dd2 Mon Sep 17 00:00:00 2001 From: Colin Young Date: Mon, 6 Oct 2025 18:59:37 -0700 Subject: [PATCH 20/21] [Codex] Use Number instead of BigInt for TokenCountEvent (#4856) Adjust to use typescript number so reduce casting and normalizing code for VSCE since js supports up to 2^53-1 --- codex-rs/protocol/src/protocol.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index bdd1d3e241..3b6520be67 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -548,10 +548,15 @@ pub struct TaskStartedEvent { #[derive(Debug, Clone, Deserialize, Serialize, Default, TS)] pub struct TokenUsage { + #[ts(type = "number")] pub input_tokens: u64, + #[ts(type = "number")] pub cached_input_tokens: u64, + #[ts(type = "number")] pub output_tokens: u64, + #[ts(type = "number")] pub reasoning_output_tokens: u64, + #[ts(type = "number")] pub total_tokens: u64, } @@ -559,6 +564,7 @@ pub struct TokenUsage { pub struct TokenUsageInfo { pub total_token_usage: TokenUsage, pub last_token_usage: TokenUsage, + #[ts(type = "number | null")] pub model_context_window: Option, } @@ -634,8 +640,10 @@ pub struct RateLimitWindow { /// Percentage (0-100) of the window that has been consumed. pub used_percent: f64, /// Rolling window duration, in minutes. + #[ts(type = "number | null")] pub window_minutes: Option, /// Seconds until the window resets. + #[ts(type = "number | null")] pub resets_in_seconds: Option, } From 35a770e871353182fb07f268d732233f6aeb02f2 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Tue, 7 Oct 2025 01:56:39 -0700 Subject: [PATCH 21/21] Simplify request body assertions (#4845) We'll have a lot more test like these --- AGENTS.md | 25 +++ codex-rs/core/tests/common/responses.rs | 141 ++++++++++++--- codex-rs/core/tests/suite/cli_stream.rs | 20 +-- codex-rs/core/tests/suite/client.rs | 52 ++---- codex-rs/core/tests/suite/model_tools.rs | 24 +-- codex-rs/core/tests/suite/read_file.rs | 30 +--- codex-rs/core/tests/suite/tool_harness.rs | 101 +++-------- codex-rs/core/tests/suite/tools.rs | 191 ++++++++------------- codex-rs/core/tests/suite/view_image.rs | 83 ++------- codex-rs/exec/tests/suite/output_schema.rs | 10 +- 10 files changed, 285 insertions(+), 392 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index accfe447f7..832f1d6543 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,3 +73,28 @@ If you don’t have the tool: ### Test assertions - Tests should use pretty_assertions::assert_eq for clearer diffs. Import this at the top of the test module if it isn't already. + +### Integration tests (core) + +- Prefer the utilities in `core_test_support::responses` when writing end-to-end Codex tests. + +- All `mount_sse*` helpers return a `ResponseMock`; hold onto it so you can assert against outbound `/responses` POST bodies. +- Use `ResponseMock::single_request()` when a test should only issue one POST, or `ResponseMock::requests()` to inspect every captured `ResponsesRequest`. +- `ResponsesRequest` exposes helpers (`body_json`, `input`, `function_call_output`, `custom_tool_call_output`, `call_output`, `header`, `path`, `query_param`) so assertions can target structured payloads instead of manual JSON digging. +- Build SSE payloads with the provided `ev_*` constructors and the `sse(...)`. + +- Typical pattern: + + ```rust + let mock = responses::mount_sse_once(&server, responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), + responses::ev_completed("resp-1"), + ])).await; + + codex.submit(Op::UserTurn { ... }).await?; + + // Assert request body if needed. + let request = mock.single_request(); + // assert using request.function_call_output(call_id) or request.json_body() or other helpers. + ``` diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 24ea824ac6..98b3eca1f4 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -1,11 +1,105 @@ +use std::sync::Arc; +use std::sync::Mutex; + use serde_json::Value; use wiremock::BodyPrintLimit; +use wiremock::Match; use wiremock::Mock; +use wiremock::MockBuilder; use wiremock::MockServer; use wiremock::Respond; use wiremock::ResponseTemplate; use wiremock::matchers::method; -use wiremock::matchers::path; +use wiremock::matchers::path_regex; + +#[derive(Debug, Clone)] +pub struct ResponseMock { + requests: Arc>>, +} + +impl ResponseMock { + fn new() -> Self { + Self { + requests: Arc::new(Mutex::new(Vec::new())), + } + } + + pub fn single_request(&self) -> ResponsesRequest { + let requests = self.requests.lock().unwrap(); + if requests.len() != 1 { + panic!("expected 1 request, got {}", requests.len()); + } + requests.first().unwrap().clone() + } + + pub fn requests(&self) -> Vec { + self.requests.lock().unwrap().clone() + } +} + +#[derive(Debug, Clone)] +pub struct ResponsesRequest(wiremock::Request); + +impl ResponsesRequest { + pub fn body_json(&self) -> Value { + self.0.body_json().unwrap() + } + + pub fn input(&self) -> Vec { + self.0.body_json::().unwrap()["input"] + .as_array() + .expect("input array not found in request") + .clone() + } + + pub fn function_call_output(&self, call_id: &str) -> Value { + self.call_output(call_id, "function_call_output") + } + + pub fn custom_tool_call_output(&self, call_id: &str) -> Value { + self.call_output(call_id, "custom_tool_call_output") + } + + pub fn call_output(&self, call_id: &str, call_type: &str) -> Value { + self.input() + .iter() + .find(|item| { + item.get("type").unwrap() == call_type && item.get("call_id").unwrap() == call_id + }) + .cloned() + .unwrap_or_else(|| panic!("function call output {call_id} item not found in request")) + } + + pub fn header(&self, name: &str) -> Option { + self.0 + .headers + .get(name) + .and_then(|v| v.to_str().ok()) + .map(str::to_string) + } + + pub fn path(&self) -> String { + self.0.url.path().to_string() + } + + pub fn query_param(&self, name: &str) -> Option { + self.0 + .url + .query_pairs() + .find(|(k, _)| k == name) + .map(|(_, v)| v.to_string()) + } +} + +impl Match for ResponseMock { + fn matches(&self, request: &wiremock::Request) -> bool { + self.requests + .lock() + .unwrap() + .push(ResponsesRequest(request.clone())); + true + } +} /// Build an SSE stream body from a list of JSON events. pub fn sse(events: Vec) -> String { @@ -161,34 +255,40 @@ pub fn sse_response(body: String) -> ResponseTemplate { .set_body_raw(body, "text/event-stream") } -pub async fn mount_sse_once_match(server: &MockServer, matcher: M, body: String) +fn base_mock() -> (MockBuilder, ResponseMock) { + let response_mock = ResponseMock::new(); + let mock = Mock::given(method("POST")) + .and(path_regex(".*/responses$")) + .and(response_mock.clone()); + (mock, response_mock) +} + +pub async fn mount_sse_once_match(server: &MockServer, matcher: M, body: String) -> ResponseMock where M: wiremock::Match + Send + Sync + 'static, { - Mock::given(method("POST")) - .and(path("/v1/responses")) - .and(matcher) + let (mock, response_mock) = base_mock(); + mock.and(matcher) .respond_with(sse_response(body)) .up_to_n_times(1) .mount(server) .await; + response_mock } -pub async fn mount_sse_once(server: &MockServer, body: String) { - Mock::given(method("POST")) - .and(path("/v1/responses")) - .respond_with(sse_response(body)) - .expect(1) +pub async fn mount_sse_once(server: &MockServer, body: String) -> ResponseMock { + let (mock, response_mock) = base_mock(); + mock.respond_with(sse_response(body)) + .up_to_n_times(1) .mount(server) .await; + response_mock } -pub async fn mount_sse(server: &MockServer, body: String) { - Mock::given(method("POST")) - .and(path("/v1/responses")) - .respond_with(sse_response(body)) - .mount(server) - .await; +pub async fn mount_sse(server: &MockServer, body: String) -> ResponseMock { + let (mock, response_mock) = base_mock(); + mock.respond_with(sse_response(body)).mount(server).await; + response_mock } pub async fn start_mock_server() -> MockServer { @@ -201,7 +301,7 @@ pub async fn start_mock_server() -> MockServer { /// Mounts a sequence of SSE response bodies and serves them in order for each /// POST to `/v1/responses`. Panics if more requests are received than bodies /// provided. Also asserts the exact number of expected calls. -pub async fn mount_sse_sequence(server: &MockServer, bodies: Vec) { +pub async fn mount_sse_sequence(server: &MockServer, bodies: Vec) -> ResponseMock { use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; @@ -228,10 +328,11 @@ pub async fn mount_sse_sequence(server: &MockServer, bodies: Vec) { responses: bodies, }; - Mock::given(method("POST")) - .and(path("/v1/responses")) - .respond_with(responder) + let (mock, response_mock) = base_mock(); + mock.respond_with(responder) .expect(num_calls as u64) .mount(server) .await; + + response_mock } diff --git a/codex-rs/core/tests/suite/cli_stream.rs b/codex-rs/core/tests/suite/cli_stream.rs index 8fc36772ad..f9408d5a9c 100644 --- a/codex-rs/core/tests/suite/cli_stream.rs +++ b/codex-rs/core/tests/suite/cli_stream.rs @@ -106,16 +106,12 @@ async fn exec_cli_applies_experimental_instructions_file() { "data: {\"type\":\"response.created\",\"response\":{}}\n\n", "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"r1\"}}\n\n" ); - Mock::given(method("POST")) - .and(path("/v1/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_raw(sse, "text/event-stream"), - ) - .expect(1) - .mount(&server) - .await; + let resp_mock = core_test_support::responses::mount_sse_once_match( + &server, + path("/v1/responses"), + sse.to_string(), + ) + .await; // Create a temporary instructions file with a unique marker we can assert // appears in the outbound request payload. @@ -164,8 +160,8 @@ async fn exec_cli_applies_experimental_instructions_file() { // Inspect the captured request and verify our custom base instructions were // included in the `instructions` field. - let request = &server.received_requests().await.unwrap()[0]; - let body = request.body_json::().unwrap(); + let request = resp_mock.single_request(); + let body = request.body_json(); let instructions = body .get("instructions") .and_then(|v| v.as_str()) diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index c49c38e30a..eb14dabb81 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -223,15 +223,9 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { // Mock server that will receive the resumed request let server = MockServer::start().await; - let first = ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_raw(sse_completed("resp1"), "text/event-stream"); - Mock::given(method("POST")) - .and(path("/v1/responses")) - .respond_with(first) - .expect(1) - .mount(&server) - .await; + let resp_mock = + responses::mount_sse_once_match(&server, path("/v1/responses"), sse_completed("resp1")) + .await; // Configure Codex to resume from our file let model_provider = ModelProviderInfo { @@ -277,8 +271,8 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { .unwrap(); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; - let request = &server.received_requests().await.unwrap()[0]; - let request_body = request.body_json::().unwrap(); + let request = resp_mock.single_request(); + let request_body = request.body_json(); let expected_input = json!([ { "type": "message", @@ -372,18 +366,9 @@ async fn includes_base_instructions_override_in_request() { skip_if_no_network!(); // Mock server let server = MockServer::start().await; - - // First request – must NOT include `previous_response_id`. - let first = ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_raw(sse_completed("resp1"), "text/event-stream"); - - Mock::given(method("POST")) - .and(path("/v1/responses")) - .respond_with(first) - .expect(1) - .mount(&server) - .await; + let resp_mock = + responses::mount_sse_once_match(&server, path("/v1/responses"), sse_completed("resp1")) + .await; let model_provider = ModelProviderInfo { base_url: Some(format!("{}/v1", server.uri())), @@ -414,8 +399,8 @@ async fn includes_base_instructions_override_in_request() { wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; - let request = &server.received_requests().await.unwrap()[0]; - let request_body = request.body_json::().unwrap(); + let request = resp_mock.single_request(); + let request_body = request.body_json(); assert!( request_body["instructions"] @@ -570,16 +555,9 @@ async fn includes_user_instructions_message_in_request() { skip_if_no_network!(); let server = MockServer::start().await; - let first = ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_raw(sse_completed("resp1"), "text/event-stream"); - - Mock::given(method("POST")) - .and(path("/v1/responses")) - .respond_with(first) - .expect(1) - .mount(&server) - .await; + let resp_mock = + responses::mount_sse_once_match(&server, path("/v1/responses"), sse_completed("resp1")) + .await; let model_provider = ModelProviderInfo { base_url: Some(format!("{}/v1", server.uri())), @@ -610,8 +588,8 @@ async fn includes_user_instructions_message_in_request() { wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; - let request = &server.received_requests().await.unwrap()[0]; - let request_body = request.body_json::().unwrap(); + let request = resp_mock.single_request(); + let request_body = request.body_json(); assert!( !request_body["instructions"] diff --git a/codex-rs/core/tests/suite/model_tools.rs b/codex-rs/core/tests/suite/model_tools.rs index 6a7c576266..ee7b44d4b0 100644 --- a/codex-rs/core/tests/suite/model_tools.rs +++ b/codex-rs/core/tests/suite/model_tools.rs @@ -10,14 +10,11 @@ use codex_core::protocol::InputItem; use codex_core::protocol::Op; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id; +use core_test_support::responses; use core_test_support::skip_if_no_network; use core_test_support::wait_for_event; use tempfile::TempDir; -use wiremock::Mock; use wiremock::MockServer; -use wiremock::ResponseTemplate; -use wiremock::matchers::method; -use wiremock::matchers::path; fn sse_completed(id: &str) -> String { load_sse_fixture_with_id("tests/fixtures/completed_template.json", id) @@ -44,16 +41,7 @@ async fn collect_tool_identifiers_for_model(model: &str) -> Vec { let server = MockServer::start().await; let sse = sse_completed(model); - let template = ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_raw(sse, "text/event-stream"); - - Mock::given(method("POST")) - .and(path("/v1/responses")) - .respond_with(template) - .expect(1) - .mount(&server) - .await; + let resp_mock = responses::mount_sse_once_match(&server, wiremock::matchers::any(), sse).await; let model_provider = ModelProviderInfo { base_url: Some(format!("{}/v1", server.uri())), @@ -93,13 +81,7 @@ async fn collect_tool_identifiers_for_model(model: &str) -> Vec { .unwrap(); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; - let requests = server.received_requests().await.unwrap(); - assert_eq!( - requests.len(), - 1, - "expected a single request for model {model}" - ); - let body = requests[0].body_json::().unwrap(); + let body = resp_mock.single_request().body_json(); tool_identifiers(&body) } diff --git a/codex-rs/core/tests/suite/read_file.rs b/codex-rs/core/tests/suite/read_file.rs index a6c8a7a181..fc5a94f91b 100644 --- a/codex-rs/core/tests/suite/read_file.rs +++ b/codex-rs/core/tests/suite/read_file.rs @@ -58,7 +58,7 @@ async fn read_file_tool_returns_requested_lines() -> anyhow::Result<()> { ev_assistant_message("msg-1", "done"), ev_completed("resp-2"), ]); - responses::mount_sse_once_match(&server, any(), second_response).await; + let second_mock = responses::mount_sse_once_match(&server, any(), second_response).await; let session_model = session_configured.model.clone(); @@ -79,36 +79,12 @@ async fn read_file_tool_returns_requested_lines() -> anyhow::Result<()> { wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; - let requests = server.received_requests().await.expect("recorded requests"); - let request_bodies = requests - .iter() - .map(|req| req.body_json::().unwrap()) - .collect::>(); - assert!( - !request_bodies.is_empty(), - "expected at least one request body" - ); - - let tool_output_item = request_bodies - .iter() - .find_map(|body| { - body.get("input") - .and_then(Value::as_array) - .and_then(|items| { - items.iter().find(|item| { - item.get("type").and_then(Value::as_str) == Some("function_call_output") - }) - }) - }) - .unwrap_or_else(|| { - panic!("function_call_output item not found in requests: {request_bodies:#?}") - }); - + let req = second_mock.single_request(); + let tool_output_item = req.function_call_output(call_id); assert_eq!( tool_output_item.get("call_id").and_then(Value::as_str), Some(call_id) ); - let output_text = tool_output_item .get("output") .and_then(|value| match value { diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 14e0e1c892..eaefe7d9dc 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -27,16 +27,6 @@ use serde_json::Value; use serde_json::json; use wiremock::matchers::any; -fn function_call_output(body: &Value) -> Option<&Value> { - body.get("input") - .and_then(Value::as_array) - .and_then(|items| { - items.iter().find(|item| { - item.get("type").and_then(Value::as_str) == Some("function_call_output") - }) - }) -} - fn extract_output_text(item: &Value) -> Option<&str> { item.get("output").and_then(|value| match value { Value::String(text) => Some(text.as_str()), @@ -45,12 +35,6 @@ fn extract_output_text(item: &Value) -> Option<&str> { }) } -fn find_request_with_function_call_output(requests: &[Value]) -> Option<&Value> { - requests - .iter() - .find(|body| function_call_output(body).is_some()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); @@ -81,7 +65,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> ev_assistant_message("msg-1", "all done"), ev_completed("resp-2"), ]); - responses::mount_sse_once_match(&server, any(), second_response).await; + let second_mock = responses::mount_sse_once_match(&server, any(), second_response).await; let session_model = session_configured.model.clone(); @@ -102,18 +86,9 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; - let requests = server.received_requests().await.expect("recorded requests"); - assert!(!requests.is_empty(), "expected at least one POST request"); - - let request_bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); - - let body_with_tool_output = find_request_with_function_call_output(&request_bodies) - .expect("function_call_output item not found in requests"); - let output_item = function_call_output(body_with_tool_output).expect("tool output item"); - let output_text = extract_output_text(output_item).expect("output text present"); + let req = second_mock.single_request(); + let output_item = req.function_call_output(call_id); + let output_text = extract_output_text(&output_item).expect("output text present"); let exec_output: Value = serde_json::from_str(output_text)?; assert_eq!(exec_output["metadata"]["exit_code"], 0); let stdout = exec_output["output"].as_str().expect("stdout field"); @@ -159,7 +134,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { ev_assistant_message("msg-1", "plan acknowledged"), ev_completed("resp-2"), ]); - responses::mount_sse_once_match(&server, any(), second_response).await; + let second_mock = responses::mount_sse_once_match(&server, any(), second_response).await; let session_model = session_configured.model.clone(); @@ -197,22 +172,13 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { assert!(saw_plan_update, "expected PlanUpdate event"); - let requests = server.received_requests().await.expect("recorded requests"); - assert!(!requests.is_empty(), "expected at least one POST request"); - - let request_bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); - - let body_with_tool_output = find_request_with_function_call_output(&request_bodies) - .expect("function_call_output item not found in requests"); - let output_item = function_call_output(body_with_tool_output).expect("tool output item"); + let req = second_mock.single_request(); + let output_item = req.function_call_output(call_id); assert_eq!( output_item.get("call_id").and_then(Value::as_str), Some(call_id) ); - let output_text = extract_output_text(output_item).expect("output text present"); + let output_text = extract_output_text(&output_item).expect("output text present"); assert_eq!(output_text, "Plan updated"); Ok(()) @@ -251,7 +217,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { ev_assistant_message("msg-1", "malformed plan payload"), ev_completed("resp-2"), ]); - responses::mount_sse_once_match(&server, any(), second_response).await; + let second_mock = responses::mount_sse_once_match(&server, any(), second_response).await; let session_model = session_configured.model.clone(); @@ -286,22 +252,13 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { "did not expect PlanUpdate event for malformed payload" ); - let requests = server.received_requests().await.expect("recorded requests"); - assert!(!requests.is_empty(), "expected at least one POST request"); - - let request_bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); - - let body_with_tool_output = find_request_with_function_call_output(&request_bodies) - .expect("function_call_output item not found in requests"); - let output_item = function_call_output(body_with_tool_output).expect("tool output item"); + let req = second_mock.single_request(); + let output_item = req.function_call_output(call_id); assert_eq!( output_item.get("call_id").and_then(Value::as_str), Some(call_id) ); - let output_text = extract_output_text(output_item).expect("output text present"); + let output_text = extract_output_text(&output_item).expect("output text present"); assert!( output_text.contains("failed to parse function arguments"), "expected parse error message in output text, got {output_text:?}" @@ -354,7 +311,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() ev_assistant_message("msg-1", "patch complete"), ev_completed("resp-2"), ]); - responses::mount_sse_once_match(&server, any(), second_response).await; + let second_mock = responses::mount_sse_once_match(&server, any(), second_response).await; let session_model = session_configured.model.clone(); @@ -395,22 +352,13 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() let patch_end_success = patch_end_success.expect("expected PatchApplyEnd event to capture success flag"); - let requests = server.received_requests().await.expect("recorded requests"); - assert!(!requests.is_empty(), "expected at least one POST request"); - - let request_bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); - - let body_with_tool_output = find_request_with_function_call_output(&request_bodies) - .expect("function_call_output item not found in requests"); - let output_item = function_call_output(body_with_tool_output).expect("tool output item"); + let req = second_mock.single_request(); + let output_item = req.function_call_output(call_id); assert_eq!( output_item.get("call_id").and_then(Value::as_str), Some(call_id) ); - let output_text = extract_output_text(output_item).expect("output text present"); + let output_text = extract_output_text(&output_item).expect("output text present"); if let Ok(exec_output) = serde_json::from_str::(output_text) { let exit_code = exec_output["metadata"]["exit_code"] @@ -480,7 +428,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { ev_assistant_message("msg-1", "failed"), ev_completed("resp-2"), ]); - responses::mount_sse_once_match(&server, any(), second_response).await; + let second_mock = responses::mount_sse_once_match(&server, any(), second_response).await; let session_model = session_configured.model.clone(); @@ -501,22 +449,13 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; - let requests = server.received_requests().await.expect("recorded requests"); - assert!(!requests.is_empty(), "expected at least one POST request"); - - let request_bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); - - let body_with_tool_output = find_request_with_function_call_output(&request_bodies) - .expect("function_call_output item not found in requests"); - let output_item = function_call_output(body_with_tool_output).expect("tool output item"); + let req = second_mock.single_request(); + let output_item = req.function_call_output(call_id); assert_eq!( output_item.get("call_id").and_then(Value::as_str), Some(call_id) ); - let output_text = extract_output_text(output_item).expect("output text present"); + let output_text = extract_output_text(&output_item).expect("output text present"); assert!( output_text.contains("apply_patch verification failed"), diff --git a/codex-rs/core/tests/suite/tools.rs b/codex-rs/core/tests/suite/tools.rs index 08826a1a72..27e709f2c3 100644 --- a/codex-rs/core/tests/suite/tools.rs +++ b/codex-rs/core/tests/suite/tools.rs @@ -15,6 +15,7 @@ use core_test_support::responses::ev_completed; use core_test_support::responses::ev_custom_tool_call; 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; @@ -25,7 +26,6 @@ use core_test_support::wait_for_event; use regex_lite::Regex; use serde_json::Value; use serde_json::json; -use wiremock::Request; async fn submit_turn( test: &TestCodex, @@ -58,27 +58,6 @@ async fn submit_turn( Ok(()) } -fn request_bodies(requests: &[Request]) -> Result> { - requests - .iter() - .map(|req| Ok(serde_json::from_slice::(&req.body)?)) - .collect() -} - -fn collect_output_items<'a>(bodies: &'a [Value], ty: &str) -> Vec<&'a Value> { - let mut out = Vec::new(); - 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(ty) { - out.push(item); - } - } - } - } - out -} - fn tool_names(body: &Value) -> Vec { body.get("tools") .and_then(Value::as_array) @@ -107,18 +86,23 @@ async fn custom_tool_unknown_returns_custom_output_error() -> Result<()> { let call_id = "custom-unsupported"; let tool_name = "unsupported_tool"; - let responses = vec![ + mount_sse_once( + &server, sse(vec![ ev_response_created("resp-1"), ev_custom_tool_call(call_id, tool_name, "\"payload\""), ev_completed("resp-1"), ]), + ) + .await; + let mock = mount_sse_once( + &server, sse(vec![ ev_assistant_message("msg-1", "done"), ev_completed("resp-2"), ]), - ]; - mount_sse_sequence(&server, responses).await; + ) + .await; submit_turn( &test, @@ -128,13 +112,7 @@ async fn custom_tool_unknown_returns_custom_output_error() -> Result<()> { ) .await?; - let requests = server.received_requests().await.expect("recorded requests"); - let bodies = request_bodies(&requests)?; - let custom_items = collect_output_items(&bodies, "custom_tool_call_output"); - assert_eq!(custom_items.len(), 1, "expected single custom tool output"); - let item = custom_items[0]; - assert_eq!(item.get("call_id").and_then(Value::as_str), Some(call_id)); - + let item = mock.single_request().custom_tool_call_output(call_id); let output = item .get("output") .and_then(Value::as_str) @@ -170,7 +148,8 @@ async fn shell_escalated_permissions_rejected_then_ok() -> Result<()> { "timeout_ms": 1_000, }); - let responses = vec![ + mount_sse_once( + &server, sse(vec![ ev_response_created("resp-1"), ev_function_call( @@ -180,6 +159,10 @@ async fn shell_escalated_permissions_rejected_then_ok() -> Result<()> { ), ev_completed("resp-1"), ]), + ) + .await; + let second_mock = mount_sse_once( + &server, sse(vec![ ev_response_created("resp-2"), ev_function_call( @@ -189,12 +172,16 @@ async fn shell_escalated_permissions_rejected_then_ok() -> Result<()> { ), ev_completed("resp-2"), ]), + ) + .await; + let third_mock = mount_sse_once( + &server, sse(vec![ ev_assistant_message("msg-1", "done"), ev_completed("resp-3"), ]), - ]; - mount_sse_sequence(&server, responses).await; + ) + .await; submit_turn( &test, @@ -204,46 +191,23 @@ async fn shell_escalated_permissions_rejected_then_ok() -> Result<()> { ) .await?; - let requests = server.received_requests().await.expect("recorded requests"); - let bodies = request_bodies(&requests)?; - let function_outputs = collect_output_items(&bodies, "function_call_output"); - for item in &function_outputs { - let call_id = item - .get("call_id") - .and_then(Value::as_str) - .unwrap_or_default(); - assert!( - call_id == call_id_blocked || call_id == call_id_success, - "unexpected call id {call_id}" - ); - } - let policy = AskForApproval::Never; let expected_message = format!( "approval policy is {policy:?}; reject command — you should not ask for escalated permissions if the approval policy is {policy:?}" ); - let blocked_outputs: Vec<&Value> = function_outputs - .iter() - .filter(|item| item.get("call_id").and_then(Value::as_str) == Some(call_id_blocked)) - .copied() - .collect(); - assert!( - !blocked_outputs.is_empty(), - "expected at least one rejection output for {call_id_blocked}" + let blocked_item = second_mock + .single_request() + .function_call_output(call_id_blocked); + assert_eq!( + blocked_item.get("output").and_then(Value::as_str), + Some(expected_message.as_str()), + "unexpected rejection message" ); - for item in blocked_outputs { - assert_eq!( - item.get("output").and_then(Value::as_str), - Some(expected_message.as_str()), - "unexpected rejection message" - ); - } - let success_item = function_outputs - .iter() - .find(|item| item.get("call_id").and_then(Value::as_str) == Some(call_id_success)) - .expect("success output present"); + let success_item = third_mock + .single_request() + .function_call_output(call_id_success); let output_json: Value = serde_json::from_str( success_item .get("output") @@ -282,18 +246,23 @@ async fn local_shell_missing_ids_maps_to_function_output_error() -> Result<()> { } }); - let responses = vec![ + mount_sse_once( + &server, sse(vec![ ev_response_created("resp-1"), local_shell_event, ev_completed("resp-1"), ]), + ) + .await; + let second_mock = mount_sse_once( + &server, sse(vec![ ev_assistant_message("msg-1", "done"), ev_completed("resp-2"), ]), - ]; - mount_sse_sequence(&server, responses).await; + ) + .await; submit_turn( &test, @@ -303,15 +272,7 @@ async fn local_shell_missing_ids_maps_to_function_output_error() -> Result<()> { ) .await?; - let requests = server.received_requests().await.expect("recorded requests"); - let bodies = request_bodies(&requests)?; - let function_outputs = collect_output_items(&bodies, "function_call_output"); - assert_eq!( - function_outputs.len(), - 1, - "expected a single function output" - ); - let item = function_outputs[0]; + let item = second_mock.single_request().function_call_output(""); assert_eq!(item.get("call_id").and_then(Value::as_str), Some("")); assert_eq!( item.get("output").and_then(Value::as_str), @@ -329,7 +290,7 @@ async fn collect_tools(use_unified_exec: bool) -> Result> { ev_assistant_message("msg-1", "done"), ev_completed("resp-1"), ])]; - mount_sse_sequence(&server, responses).await; + let mock = mount_sse_sequence(&server, responses).await; let mut builder = test_codex().with_config(move |config| { config.use_experimental_unified_exec_tool = use_unified_exec; @@ -344,15 +305,8 @@ async fn collect_tools(use_unified_exec: bool) -> Result> { ) .await?; - let requests = server.received_requests().await.expect("recorded requests"); - assert_eq!( - requests.len(), - 1, - "expected a single request for tools collection" - ); - let bodies = request_bodies(&requests)?; - let first_body = bodies.first().expect("request body present"); - Ok(tool_names(first_body)) + let first_body = mock.single_request().body_json(); + Ok(tool_names(&first_body)) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -392,18 +346,23 @@ async fn shell_timeout_includes_timeout_prefix_and_metadata() -> Result<()> { "timeout_ms": timeout_ms, }); - let responses = vec![ + mount_sse_once( + &server, sse(vec![ ev_response_created("resp-1"), ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), + ) + .await; + let second_mock = mount_sse_once( + &server, sse(vec![ ev_assistant_message("msg-1", "done"), ev_completed("resp-2"), ]), - ]; - mount_sse_sequence(&server, responses).await; + ) + .await; submit_turn( &test, @@ -413,13 +372,7 @@ async fn shell_timeout_includes_timeout_prefix_and_metadata() -> Result<()> { ) .await?; - let requests = server.received_requests().await.expect("recorded requests"); - let bodies = request_bodies(&requests)?; - let function_outputs = collect_output_items(&bodies, "function_call_output"); - let timeout_item = function_outputs - .iter() - .find(|item| item.get("call_id").and_then(Value::as_str) == Some(call_id)) - .expect("timeout output present"); + let timeout_item = second_mock.single_request().function_call_output(call_id); let output_str = timeout_item .get("output") @@ -478,18 +431,23 @@ async fn shell_sandbox_denied_truncates_error_output() -> Result<()> { "timeout_ms": 1_000, }); - let responses = vec![ + mount_sse_once( + &server, sse(vec![ ev_response_created("resp-1"), ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), + ) + .await; + let second_mock = mount_sse_once( + &server, sse(vec![ ev_assistant_message("msg-1", "done"), ev_completed("resp-2"), ]), - ]; - mount_sse_sequence(&server, responses).await; + ) + .await; submit_turn( &test, @@ -499,13 +457,7 @@ async fn shell_sandbox_denied_truncates_error_output() -> Result<()> { ) .await?; - let requests = server.received_requests().await.expect("recorded requests"); - let bodies = request_bodies(&requests)?; - let function_outputs = collect_output_items(&bodies, "function_call_output"); - let denied_item = function_outputs - .iter() - .find(|item| item.get("call_id").and_then(Value::as_str) == Some(call_id)) - .expect("denied output present"); + let denied_item = second_mock.single_request().function_call_output(call_id); let output = denied_item .get("output") @@ -558,18 +510,23 @@ async fn shell_spawn_failure_truncates_exec_error() -> Result<()> { "timeout_ms": 1_000, }); - let responses = vec![ + mount_sse_once( + &server, sse(vec![ ev_response_created("resp-1"), ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), + ) + .await; + let second_mock = mount_sse_once( + &server, sse(vec![ ev_assistant_message("msg-1", "done"), ev_completed("resp-2"), ]), - ]; - mount_sse_sequence(&server, responses).await; + ) + .await; submit_turn( &test, @@ -579,13 +536,7 @@ async fn shell_spawn_failure_truncates_exec_error() -> Result<()> { ) .await?; - let requests = server.received_requests().await.expect("recorded requests"); - let bodies = request_bodies(&requests)?; - let function_outputs = collect_output_items(&bodies, "function_call_output"); - let failure_item = function_outputs - .iter() - .find(|item| item.get("call_id").and_then(Value::as_str) == Some(call_id)) - .expect("spawn failure output present"); + let failure_item = second_mock.single_request().function_call_output(call_id); let output = failure_item .get("output") diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 16913f4f93..bdb67ad631 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -22,16 +22,6 @@ use core_test_support::wait_for_event; use serde_json::Value; use wiremock::matchers::any; -fn function_call_output(body: &Value) -> Option<&Value> { - body.get("input") - .and_then(Value::as_array) - .and_then(|items| { - items.iter().find(|item| { - item.get("type").and_then(Value::as_str) == Some("function_call_output") - }) - }) -} - fn find_image_message(body: &Value) -> Option<&Value> { body.get("input") .and_then(Value::as_array) @@ -59,12 +49,6 @@ fn extract_output_text(item: &Value) -> Option<&str> { }) } -fn find_request_with_function_call_output(requests: &[Value]) -> Option<&Value> { - requests - .iter() - .find(|body| function_call_output(body).is_some()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); @@ -100,7 +84,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { ev_assistant_message("msg-1", "done"), ev_completed("resp-2"), ]); - responses::mount_sse_once_match(&server, any(), second_response).await; + let mock = responses::mount_sse_once_match(&server, any(), second_response).await; let session_model = session_configured.model.clone(); @@ -137,25 +121,14 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { assert_eq!(tool_event.call_id, call_id); assert_eq!(tool_event.path, abs_path); - let requests = server.received_requests().await.expect("recorded requests"); - assert!( - requests.len() >= 2, - "expected at least two POST requests, got {}", - requests.len() - ); - let request_bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let body = mock.single_request().body_json(); + let output_item = mock.single_request().function_call_output(call_id); - let body_with_tool_output = find_request_with_function_call_output(&request_bodies) - .expect("function_call_output item not found in requests"); - let output_item = function_call_output(body_with_tool_output).expect("tool output item"); - let output_text = extract_output_text(output_item).expect("output text present"); + let output_text = extract_output_text(&output_item).expect("output text present"); assert_eq!(output_text, "attached local image path"); - let image_message = find_image_message(body_with_tool_output) - .expect("pending input image message not included in request"); + let image_message = + find_image_message(&body).expect("pending input image message not included in request"); let image_url = image_message .get("content") .and_then(Value::as_array) @@ -210,7 +183,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { ev_assistant_message("msg-1", "done"), ev_completed("resp-2"), ]); - responses::mount_sse_once_match(&server, any(), second_response).await; + let mock = responses::mount_sse_once_match(&server, any(), second_response).await; let session_model = session_configured.model.clone(); @@ -231,26 +204,14 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; - let requests = server.received_requests().await.expect("recorded requests"); - assert!( - requests.len() >= 2, - "expected at least two POST requests, got {}", - requests.len() - ); - let request_bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); - - let body_with_tool_output = find_request_with_function_call_output(&request_bodies) - .expect("function_call_output item not found in requests"); - let output_item = function_call_output(body_with_tool_output).expect("tool output item"); - let output_text = extract_output_text(output_item).expect("output text present"); + let body_with_tool_output = mock.single_request().body_json(); + let output_item = mock.single_request().function_call_output(call_id); + let output_text = extract_output_text(&output_item).expect("output text present"); let expected_message = format!("image path `{}` is not a file", abs_path.display()); assert_eq!(output_text, expected_message); assert!( - find_image_message(body_with_tool_output).is_none(), + find_image_message(&body_with_tool_output).is_none(), "directory path should not produce an input_image message" ); @@ -287,7 +248,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { ev_assistant_message("msg-1", "done"), ev_completed("resp-2"), ]); - responses::mount_sse_once_match(&server, any(), second_response).await; + let mock = responses::mount_sse_once_match(&server, any(), second_response).await; let session_model = session_configured.model.clone(); @@ -308,21 +269,9 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; - let requests = server.received_requests().await.expect("recorded requests"); - assert!( - requests.len() >= 2, - "expected at least two POST requests, got {}", - requests.len() - ); - let request_bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); - - let body_with_tool_output = find_request_with_function_call_output(&request_bodies) - .expect("function_call_output item not found in requests"); - let output_item = function_call_output(body_with_tool_output).expect("tool output item"); - let output_text = extract_output_text(output_item).expect("output text present"); + let body_with_tool_output = mock.single_request().body_json(); + let output_item = mock.single_request().function_call_output(call_id); + let output_text = extract_output_text(&output_item).expect("output text present"); let expected_prefix = format!("unable to locate image at `{}`:", abs_path.display()); assert!( output_text.starts_with(&expected_prefix), @@ -330,7 +279,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { ); assert!( - find_image_message(body_with_tool_output).is_none(), + find_image_message(&body_with_tool_output).is_none(), "missing file should not produce an input_image message" ); diff --git a/codex-rs/exec/tests/suite/output_schema.rs b/codex-rs/exec/tests/suite/output_schema.rs index b054484f7c..913270eff4 100644 --- a/codex-rs/exec/tests/suite/output_schema.rs +++ b/codex-rs/exec/tests/suite/output_schema.rs @@ -28,7 +28,7 @@ async fn exec_includes_output_schema_in_request() -> anyhow::Result<()> { responses::ev_assistant_message("m1", "fixture hello"), responses::ev_completed("resp1"), ]); - responses::mount_sse_once_match(&server, any(), body).await; + let response_mock = responses::mount_sse_once_match(&server, any(), body).await; test.cmd_with_server(&server) .arg("--skip-git-repo-check") @@ -43,12 +43,8 @@ async fn exec_includes_output_schema_in_request() -> anyhow::Result<()> { .assert() .success(); - let requests = server - .received_requests() - .await - .expect("failed to capture requests"); - assert_eq!(requests.len(), 1, "expected exactly one request"); - let payload: Value = serde_json::from_slice(&requests[0].body)?; + let request = response_mock.single_request(); + let payload: Value = request.body_json(); let text = payload.get("text").expect("request missing text field"); let format = text .get("format")