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()
+}