From 94f5cad895df39b43edf565462c5b5d382ce441f Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sat, 12 Jul 2025 16:22:02 -0700 Subject: [PATCH 1/5] fix: when invoking Codex via MCP, use the request id as the Submission id (#1554) Small quality-of-life improvement when using `codex mcp`. --- codex-rs/mcp-server/src/codex_tool_runner.rs | 21 +++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 796a119e5c..7c3b02fe5e 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -9,6 +9,7 @@ use codex_core::protocol::Event; use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; +use codex_core::protocol::Submission; use codex_core::protocol::TaskCompleteEvent; use mcp_types::CallToolResult; use mcp_types::CallToolResultContent; @@ -66,14 +67,24 @@ pub async fn run_codex_tool_session( .send(codex_event_to_notification(&first_event)) .await; - if let Err(e) = codex - .submit(Op::UserInput { + // Use the original MCP request ID as the `sub_id` for the Codex submission so that + // any events emitted for this tool-call can be correlated with the + // originating `tools/call` request. + let sub_id = match &id { + RequestId::String(s) => s.clone(), + RequestId::Integer(n) => n.to_string(), + }; + + let submission = Submission { + id: sub_id, + op: Op::UserInput { items: vec![InputItem::Text { text: initial_prompt.clone(), }], - }) - .await - { + }, + }; + + if let Err(e) = codex.submit_with_id(submission).await { tracing::error!("Failed to submit initial prompt: {e}"); } From c46bb67d77eb366596e2039b35ae29ce02ed14bf Mon Sep 17 00:00:00 2001 From: aibrahim-oai Date: Sat, 12 Jul 2025 16:53:55 -0700 Subject: [PATCH 2/5] Improve SSE tests (#1546) ## Summary - support fixture-based SSE data in tests - add helpers to load SSE JSON fixtures - add table-driven SSE unit tests - let integration tests use fixture loading - fix clippy errors from format! calls ## Testing - `cargo clippy --tests` - `cargo test --workspace --exclude codex-linux-sandbox` ------ https://chatgpt.com/codex/tasks/task_i_68717468c3e48321b51c9ecac6ba0f09 --- codex-rs/core/src/client.rs | 113 ++++++++++++++++++ .../tests/fixtures/completed_template.json | 16 +++ .../core/tests/fixtures/incomplete_sse.json | 3 + codex-rs/core/tests/previous_response_id.rs | 8 +- codex-rs/core/tests/stream_no_completed.rs | 10 +- codex-rs/core/tests/test_support.rs | 53 ++++++++ 6 files changed, 192 insertions(+), 11 deletions(-) create mode 100644 codex-rs/core/tests/fixtures/completed_template.json create mode 100644 codex-rs/core/tests/fixtures/incomplete_sse.json diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index bd2eeb9457..1b8e4c959d 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -391,3 +391,116 @@ async fn stream_from_fixture(path: impl AsRef) -> Result { tokio::spawn(process_sse(stream, tx_event)); Ok(ResponseStream { rx_event }) } + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, clippy::unwrap_used)] + use super::*; + use serde_json::json; + + async fn run_sse(events: Vec) -> Vec { + let mut body = String::new(); + for e in events { + let kind = e + .get("type") + .and_then(|v| v.as_str()) + .expect("fixture event missing type"); + if e.as_object().map(|o| o.len() == 1).unwrap_or(false) { + body.push_str(&format!("event: {kind}\n\n")); + } else { + body.push_str(&format!("event: {kind}\ndata: {e}\n\n")); + } + } + let (tx, mut rx) = mpsc::channel::>(8); + let stream = ReaderStream::new(std::io::Cursor::new(body)).map_err(CodexErr::Io); + tokio::spawn(process_sse(stream, tx)); + let mut out = Vec::new(); + while let Some(ev) = rx.recv().await { + out.push(ev.expect("channel closed")); + } + out + } + + /// Verifies that the SSE adapter emits the expected [`ResponseEvent`] for + /// a variety of `type` values from the Responses API. The test is written + /// table-driven style to keep additions for new event kinds trivial. + /// + /// Each `Case` supplies an input event, a predicate that must match the + /// *first* `ResponseEvent` produced by the adapter, and the total number + /// of events expected after appending a synthetic `response.completed` + /// marker that terminates the stream. + #[tokio::test] + async fn table_driven_event_kinds() { + struct TestCase { + name: &'static str, + event: serde_json::Value, + expect_first: fn(&ResponseEvent) -> bool, + expected_len: usize, + } + + fn is_created(ev: &ResponseEvent) -> bool { + matches!(ev, ResponseEvent::Created) + } + + fn is_output(ev: &ResponseEvent) -> bool { + matches!(ev, ResponseEvent::OutputItemDone(_)) + } + + fn is_completed(ev: &ResponseEvent) -> bool { + matches!(ev, ResponseEvent::Completed { .. }) + } + + let completed = json!({ + "type": "response.completed", + "response": { + "id": "c", + "usage": { + "input_tokens": 0, + "input_tokens_details": null, + "output_tokens": 0, + "output_tokens_details": null, + "total_tokens": 0 + }, + "output": [] + } + }); + + let cases = vec![ + TestCase { + name: "created", + event: json!({"type": "response.created", "response": {}}), + expect_first: is_created, + expected_len: 2, + }, + TestCase { + name: "output_item.done", + event: json!({ + "type": "response.output_item.done", + "item": { + "type": "message", + "role": "assistant", + "content": [ + {"type": "output_text", "text": "hi"} + ] + } + }), + expect_first: is_output, + expected_len: 2, + }, + TestCase { + name: "unknown", + event: json!({"type": "response.new_tool_event"}), + expect_first: is_completed, + expected_len: 1, + }, + ]; + + for case in cases { + let mut evs = vec![case.event]; + evs.push(completed.clone()); + let out = run_sse(evs).await; + assert_eq!(out.len(), case.expected_len, "case {}", case.name); + assert!((case.expect_first)(&out[0]), "case {}", case.name); + } + } +} diff --git a/codex-rs/core/tests/fixtures/completed_template.json b/codex-rs/core/tests/fixtures/completed_template.json new file mode 100644 index 0000000000..1774dc5e84 --- /dev/null +++ b/codex-rs/core/tests/fixtures/completed_template.json @@ -0,0 +1,16 @@ +[ + { + "type": "response.completed", + "response": { + "id": "__ID__", + "usage": { + "input_tokens": 0, + "input_tokens_details": null, + "output_tokens": 0, + "output_tokens_details": null, + "total_tokens": 0 + }, + "output": [] + } + } +] diff --git a/codex-rs/core/tests/fixtures/incomplete_sse.json b/codex-rs/core/tests/fixtures/incomplete_sse.json new file mode 100644 index 0000000000..2876bbfd29 --- /dev/null +++ b/codex-rs/core/tests/fixtures/incomplete_sse.json @@ -0,0 +1,3 @@ +[ + {"type": "response.output_item.done"} +] diff --git a/codex-rs/core/tests/previous_response_id.rs b/codex-rs/core/tests/previous_response_id.rs index 10d6e8bf6a..e64271a0ff 100644 --- a/codex-rs/core/tests/previous_response_id.rs +++ b/codex-rs/core/tests/previous_response_id.rs @@ -11,6 +11,7 @@ mod test_support; use serde_json::Value; use tempfile::TempDir; use test_support::load_default_config_for_test; +use test_support::load_sse_fixture_with_id; use tokio::time::timeout; use wiremock::Match; use wiremock::Mock; @@ -42,12 +43,9 @@ impl Match for HasPrevId { } } -/// Build minimal SSE stream with completed marker. +/// Build minimal SSE stream with completed marker using the JSON fixture. fn sse_completed(id: &str) -> String { - format!( - "event: response.completed\n\ -data: {{\"type\":\"response.completed\",\"response\":{{\"id\":\"{id}\",\"output\":[]}}}}\n\n\n" - ) + load_sse_fixture_with_id("tests/fixtures/completed_template.json", id) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/codex-rs/core/tests/stream_no_completed.rs b/codex-rs/core/tests/stream_no_completed.rs index ece34ba299..da2736aa77 100644 --- a/codex-rs/core/tests/stream_no_completed.rs +++ b/codex-rs/core/tests/stream_no_completed.rs @@ -12,6 +12,8 @@ use codex_core::protocol::Op; mod test_support; use tempfile::TempDir; use test_support::load_default_config_for_test; +use test_support::load_sse_fixture; +use test_support::load_sse_fixture_with_id; use tokio::time::timeout; use wiremock::Mock; use wiremock::MockServer; @@ -22,15 +24,11 @@ use wiremock::matchers::method; use wiremock::matchers::path; fn sse_incomplete() -> String { - // Only a single line; missing the completed event. - "event: response.output_item.done\n\n".to_string() + load_sse_fixture("tests/fixtures/incomplete_sse.json") } fn sse_completed(id: &str) -> String { - format!( - "event: response.completed\n\ -data: {{\"type\":\"response.completed\",\"response\":{{\"id\":\"{id}\",\"output\":[]}}}}\n\n\n" - ) + load_sse_fixture_with_id("tests/fixtures/completed_template.json", id) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/codex-rs/core/tests/test_support.rs b/codex-rs/core/tests/test_support.rs index 532e3986d0..5dbe637101 100644 --- a/codex-rs/core/tests/test_support.rs +++ b/codex-rs/core/tests/test_support.rs @@ -21,3 +21,56 @@ pub fn load_default_config_for_test(codex_home: &TempDir) -> Config { ) .expect("defaults for test should always succeed") } + +/// Builds an SSE stream body from a JSON fixture. +/// +/// The fixture must contain an array of objects where each object represents a +/// single SSE event with at least a `type` field matching the `event:` value. +/// Additional fields become the JSON payload for the `data:` line. An object +/// with only a `type` field results in an event with no `data:` section. This +/// makes it trivial to extend the fixtures as OpenAI adds new event kinds or +/// fields. +pub fn load_sse_fixture(path: impl AsRef) -> String { + let events: Vec = + serde_json::from_reader(std::fs::File::open(path).expect("read fixture")) + .expect("parse JSON fixture"); + events + .into_iter() + .map(|e| { + let kind = e + .get("type") + .and_then(|v| v.as_str()) + .expect("fixture event missing type"); + if e.as_object().map(|o| o.len() == 1).unwrap_or(false) { + format!("event: {kind}\n\n") + } else { + format!("event: {kind}\ndata: {e}\n\n") + } + }) + .collect() +} + +/// Same as [`load_sse_fixture`], but replaces the placeholder `__ID__` in the +/// fixture template with the supplied identifier before parsing. This lets a +/// single JSON template be reused by multiple tests that each need a unique +/// `response_id`. +pub fn load_sse_fixture_with_id(path: impl AsRef, id: &str) -> String { + let raw = std::fs::read_to_string(path).expect("read fixture template"); + let replaced = raw.replace("__ID__", id); + let events: Vec = + serde_json::from_str(&replaced).expect("parse JSON fixture"); + events + .into_iter() + .map(|e| { + let kind = e + .get("type") + .and_then(|v| v.as_str()) + .expect("fixture event missing type"); + if e.as_object().map(|o| o.len() == 1).unwrap_or(false) { + format!("event: {kind}\n\n") + } else { + format!("event: {kind}\ndata: {e}\n\n") + } + }) + .collect() +} From 0f8ac923904716e77f73b8e8f34b6891e4a88287 Mon Sep 17 00:00:00 2001 From: aibrahim-oai Date: Sat, 12 Jul 2025 17:20:35 -0700 Subject: [PATCH 3/5] Allow deadcode in test_support (#1555) #1546 Was pushed while not passing the clippy integration tests. This is fixing it. --- codex-rs/core/tests/test_support.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/codex-rs/core/tests/test_support.rs b/codex-rs/core/tests/test_support.rs index 5dbe637101..7d1e3a7fef 100644 --- a/codex-rs/core/tests/test_support.rs +++ b/codex-rs/core/tests/test_support.rs @@ -30,6 +30,7 @@ pub fn load_default_config_for_test(codex_home: &TempDir) -> Config { /// with only a `type` field results in an event with no `data:` section. This /// makes it trivial to extend the fixtures as OpenAI adds new event kinds or /// fields. +#[allow(dead_code)] pub fn load_sse_fixture(path: impl AsRef) -> String { let events: Vec = serde_json::from_reader(std::fs::File::open(path).expect("read fixture")) @@ -54,6 +55,7 @@ pub fn load_sse_fixture(path: impl AsRef) -> String { /// fixture template with the supplied identifier before parsing. This lets a /// single JSON template be reused by multiple tests that each need a unique /// `response_id`. +#[allow(dead_code)] pub fn load_sse_fixture_with_id(path: impl AsRef, id: &str) -> String { let raw = std::fs::read_to_string(path).expect("read fixture template"); let replaced = raw.replace("__ID__", id); From 3777e18243e51ea53f55a1a43cd658a141e7a272 Mon Sep 17 00:00:00 2001 From: aibrahim-oai Date: Sat, 12 Jul 2025 18:05:58 -0700 Subject: [PATCH 4/5] Add CLI streaming integration tests (#1542) ## Summary - add integration test for chat mode streaming via CLI using wiremock - add integration test for Responses API streaming via fixture - call `cargo run` to invoke the CLI during tests ## Testing - `cargo test -p codex-core --test cli_stream -- --nocapture` - `cargo clippy --all-targets --all-features -- -D warnings` ------ https://chatgpt.com/codex/tasks/task_i_68715980bbec8321999534fdd6a013c1 --- codex-rs/core/tests/cli_responses_fixture.sse | 8 ++ codex-rs/core/tests/cli_stream.rs | 119 ++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 codex-rs/core/tests/cli_responses_fixture.sse create mode 100644 codex-rs/core/tests/cli_stream.rs diff --git a/codex-rs/core/tests/cli_responses_fixture.sse b/codex-rs/core/tests/cli_responses_fixture.sse new file mode 100644 index 0000000000..d297ebafb2 --- /dev/null +++ b/codex-rs/core/tests/cli_responses_fixture.sse @@ -0,0 +1,8 @@ +event: response.created +data: {"type":"response.created","response":{"id":"resp1"}} + +event: response.output_item.done +data: {"type":"response.output_item.done","item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"fixture hello"}]}} + +event: response.completed +data: {"type":"response.completed","response":{"id":"resp1","output":[]}} diff --git a/codex-rs/core/tests/cli_stream.rs b/codex-rs/core/tests/cli_stream.rs new file mode 100644 index 0000000000..df3fedfd48 --- /dev/null +++ b/codex-rs/core/tests/cli_stream.rs @@ -0,0 +1,119 @@ +#![expect(clippy::unwrap_used)] + +use assert_cmd::Command as AssertCommand; +use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; +use tempfile::TempDir; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +/// Tests streaming chat completions through the CLI using a mock server. +/// This test: +/// 1. Sets up a mock server that simulates OpenAI's chat completions API +/// 2. Configures codex to use this mock server via a custom provider +/// 3. Sends a simple "hello?" prompt and verifies the streamed response +/// 4. Ensures the response is received exactly once and contains "hi" +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn chat_mode_stream_cli() { + if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + let server = MockServer::start().await; + let sse = concat!( + "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n", + "data: {\"choices\":[{\"delta\":{}}]}\n\n", + "data: [DONE]\n\n" + ); + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .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 home = TempDir::new().unwrap(); + let provider_override = format!( + "model_providers.mock={{ name = \"mock\", base_url = \"{}/v1\", env_key = \"PATH\", wire_api = \"chat\" }}", + server.uri() + ); + let mut cmd = AssertCommand::new("cargo"); + cmd.arg("run") + .arg("-p") + .arg("codex-cli") + .arg("--quiet") + .arg("--") + .arg("exec") + .arg("--skip-git-repo-check") + .arg("-c") + .arg(&provider_override) + .arg("-c") + .arg("model_provider=\"mock\"") + .arg("-C") + .arg(env!("CARGO_MANIFEST_DIR")) + .arg("hello?"); + cmd.env("CODEX_HOME", home.path()) + .env("OPENAI_API_KEY", "dummy") + .env("OPENAI_BASE_URL", format!("{}/v1", server.uri())); + + let output = cmd.output().unwrap(); + println!("Status: {}", output.status); + println!("Stdout:\n{}", String::from_utf8_lossy(&output.stdout)); + println!("Stderr:\n{}", String::from_utf8_lossy(&output.stderr)); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("hi")); + assert_eq!(stdout.matches("hi").count(), 1); + + server.verify().await; +} + +/// Tests streaming responses through the CLI using a local SSE fixture file. +/// This test: +/// 1. Uses a pre-recorded SSE response fixture instead of a live server +/// 2. Configures codex to read from this fixture via CODEX_RS_SSE_FIXTURE env var +/// 3. Sends a "hello?" prompt and verifies the response +/// 4. Ensures the fixture content is correctly streamed through the CLI +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_api_stream_cli() { + if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + let fixture = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/cli_responses_fixture.sse"); + + let home = TempDir::new().unwrap(); + let mut cmd = AssertCommand::new("cargo"); + cmd.arg("run") + .arg("-p") + .arg("codex-cli") + .arg("--quiet") + .arg("--") + .arg("exec") + .arg("--skip-git-repo-check") + .arg("-C") + .arg(env!("CARGO_MANIFEST_DIR")) + .arg("hello?"); + cmd.env("CODEX_HOME", home.path()) + .env("OPENAI_API_KEY", "dummy") + .env("CODEX_RS_SSE_FIXTURE", fixture) + .env("OPENAI_BASE_URL", "http://unused.local"); + + let output = cmd.output().unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("fixture hello")); +} From ff99fae4e4bea1a17a569b0488cb0823c114baa6 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 14 Jul 2025 09:39:42 -0700 Subject: [PATCH 5/5] docs: clarify the build process for the npm release --- codex-cli/scripts/README.md | 9 +++++++++ codex-cli/scripts/stage_release.sh | 8 +++----- 2 files changed, 12 insertions(+), 5 deletions(-) create mode 100644 codex-cli/scripts/README.md diff --git a/codex-cli/scripts/README.md b/codex-cli/scripts/README.md new file mode 100644 index 0000000000..21e4f3e883 --- /dev/null +++ b/codex-cli/scripts/README.md @@ -0,0 +1,9 @@ +# npm releases + +Run the following: + +To build the 0.2.x or later version of the npm module, which runs the Rust version of the CLI, build it as follows: + +```bash +./codex-cli/scripts/stage_rust_release.py --release-version 0.6.0 +``` diff --git a/codex-cli/scripts/stage_release.sh b/codex-cli/scripts/stage_release.sh index 29b9f76783..cd32ade6f9 100755 --- a/codex-cli/scripts/stage_release.sh +++ b/codex-cli/scripts/stage_release.sh @@ -4,10 +4,7 @@ # ----------------------------------------------------------------------------- # Stages an npm release for @openai/codex. # -# The script used to accept a single optional positional argument that indicated -# the temporary directory in which to stage the package. We now support a -# flag-based interface so that we can extend the command with further options -# without breaking the call-site contract. +# Usage: # # --tmp : Use instead of a freshly created temp directory. # --native : Bundle the pre-built Rust CLI binaries for Linux alongside @@ -141,7 +138,8 @@ popd >/dev/null echo "Staged version $VERSION for release in $TMPDIR" if [[ "$INCLUDE_NATIVE" -eq 1 ]]; then - echo "Test Rust:" + echo "Verify the CLI:" + echo " node ${TMPDIR}/bin/codex.js --version" echo " node ${TMPDIR}/bin/codex.js --help" else echo "Test Node:"