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:" 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/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")); +} 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..7d1e3a7fef 100644 --- a/codex-rs/core/tests/test_support.rs +++ b/codex-rs/core/tests/test_support.rs @@ -21,3 +21,58 @@ 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. +#[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")) + .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`. +#[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); + 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() +}