Merge remote-tracking branch 'origin/jif/parallel-tool-call-prompt' into jif/parallel-tool-call-prompt

This commit is contained in:
jimmyfraiture
2025-10-07 15:33:08 +01:00
77 changed files with 1197 additions and 942 deletions
+6 -1
View File
@@ -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)*'];
+25
View File
@@ -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.
```
+1 -1
View File
@@ -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
+20 -15
View File
@@ -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",
@@ -980,7 +988,6 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
"throbber-widgets-tui",
"tokio",
"tokio-stream",
"tracing",
@@ -1022,6 +1029,7 @@ dependencies = [
"anyhow",
"askama",
"assert_cmd",
"assert_matches",
"async-channel",
"async-trait",
"base64",
@@ -1162,6 +1170,7 @@ dependencies = [
name = "codex-git-tooling"
version = "0.0.0"
dependencies = [
"assert_matches",
"pretty_assertions",
"tempfile",
"thiserror 2.0.16",
@@ -1249,6 +1258,7 @@ dependencies = [
name = "codex-ollama"
version = "0.0.0"
dependencies = [
"assert_matches",
"async-stream",
"bytes",
"codex-core",
@@ -1367,6 +1377,7 @@ version = "0.0.0"
dependencies = [
"anyhow",
"arboard",
"assert_matches",
"async-stream",
"base64",
"chrono",
@@ -1432,6 +1443,7 @@ dependencies = [
name = "codex-utils-readiness"
version = "0.0.0"
dependencies = [
"assert_matches",
"async-trait",
"thiserror 2.0.16",
"time",
@@ -1562,6 +1574,7 @@ dependencies = [
"anyhow",
"assert_cmd",
"codex-core",
"regex-lite",
"serde_json",
"tempfile",
"tokio",
@@ -4757,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",
@@ -4790,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",
@@ -5827,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"
+2 -4
View File
@@ -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"
@@ -142,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"
@@ -250,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 }
+13 -3
View File
@@ -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
@@ -71,9 +77,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]...
```
+1
View File
@@ -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 }
+7 -6
View File
@@ -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]
+1
View File
@@ -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 }
+20 -15
View File
@@ -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)]
@@ -291,7 +294,8 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> 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(
@@ -341,8 +345,8 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> 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 +357,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> 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(),
@@ -472,6 +476,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 +609,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(),
+18 -16
View File
@@ -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"
+3 -4
View File
@@ -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<TaskSummary>,
pub selected: usize,
pub status: String,
pub diff_overlay: Option<DiffOverlay>,
pub throbber: ThrobberState,
pub spinner_start: Option<Instant>,
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,
+7 -3
View File
@@ -400,16 +400,20 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> 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)?;
}
+33 -22
View File
@@ -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<Line> = Vec::new();
let first = match m.result_level {
@@ -859,29 +865,29 @@ fn format_relative_time(ts: chrono::DateTime<Utc>) -> String {
fn draw_inline_spinner(
frame: &mut Frame,
area: Rect,
state: &mut throbber_widgets_tui::ThrobberState,
spinner_start: &mut Option<Instant>,
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<Instant>,
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;
}
+1
View File
@@ -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 }
+1 -1
View File
@@ -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
+3 -5
View File
@@ -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\"");
+12 -1
View File
@@ -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<u32> {
total_segment.parse::<u32>().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,
+4 -6
View File
@@ -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()]
));
);
}
}
+2 -1
View File
@@ -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]
+2 -1
View File
@@ -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:?}"),
+6 -5
View File
@@ -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]
+1
View File
@@ -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"] }
+11
View File
@@ -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`.
+131 -20
View File
@@ -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<Mutex<Vec<ResponsesRequest>>>,
}
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<ResponsesRequest> {
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<Value> {
self.0.body_json::<Value>().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<String> {
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<String> {
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<Value>) -> String {
@@ -34,6 +128,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",
@@ -151,34 +255,40 @@ pub fn sse_response(body: String) -> ResponseTemplate {
.set_body_raw(body, "text/event-stream")
}
pub async fn mount_sse_once_match<M>(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<M>(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 {
@@ -191,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<String>) {
pub async fn mount_sse_sequence(server: &MockServer, bodies: Vec<String>) -> ResponseMock {
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
@@ -218,10 +328,11 @@ pub async fn mount_sse_sequence(server: &MockServer, bodies: Vec<String>) {
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
}
+8 -12
View File
@@ -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::<serde_json::Value>().unwrap();
let request = resp_mock.single_request();
let body = request.body_json();
let instructions = body
.get("instructions")
.and_then(|v| v.as_str())
+15 -37
View File
@@ -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::<serde_json::Value>().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::<serde_json::Value>().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::<serde_json::Value>().unwrap();
let request = resp_mock.single_request();
let request_body = request.body_json();
assert!(
!request_body["instructions"]
+16 -111
View File
@@ -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<Vec<String>>,
calls: Arc<AtomicUsize>,
requests: Arc<Mutex<Vec<Vec<u8>>>>,
}
impl SeqResponder {
fn new(bodies: Vec<String>) -> Self {
Self {
bodies: Arc::new(bodies),
calls: Arc::new(AtomicUsize::new(0)),
requests: Arc::new(Mutex::new(Vec::new())),
}
}
fn recorded_requests(&self) -> Vec<Vec<u8>> {
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<String> = responder
.recorded_requests()
let request_bodies: Vec<String> = 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(),
+3 -21
View File
@@ -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<String> {
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<String> {
.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::<serde_json::Value>().unwrap();
let body = resp_mock.single_request().body_json();
tool_identifiers(&body)
}
+5 -31
View File
@@ -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"),
]);
@@ -60,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();
@@ -81,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::<Value>().unwrap())
.collect::<Vec<_>>();
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 {
+3 -12
View File
@@ -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"),
]),
@@ -8,9 +8,11 @@ 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;
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 +90,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"),
]),
@@ -130,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(())
}
@@ -155,7 +154,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"),
]),
@@ -189,18 +188,12 @@ async fn shell_output_is_structured_with_freeform_apply_patch() -> Result<()> {
serde_json::from_str::<Value>(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(())
}
@@ -224,7 +217,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"),
]),
@@ -258,18 +251,27 @@ async fn shell_output_reserializes_truncated_content() -> Result<()> {
serde_json::from_str::<Value>(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:
1
2
3
4
5
6
.*
\[\.{3} omitted \d+ of 400 lines \.{3}\]
.*
396
397
398
399
400
$"#;
assert_regex_match(truncated_pattern, output);
Ok(())
}
+31 -107
View File
@@ -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;
@@ -8,12 +9,14 @@ 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;
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;
@@ -24,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()),
@@ -42,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(()));
@@ -68,10 +55,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"),
]);
@@ -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,25 +86,13 @@ 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::<Value>().expect("request json"))
.collect::<Vec<_>>();
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");
assert!(
stdout.contains("tool harness"),
"expected stdout to contain command output, got {stdout:?}"
);
assert_regex_match(r"(?s)^tool harness\n?$", stdout);
Ok(())
}
@@ -152,10 +124,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"),
]);
@@ -165,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();
@@ -191,9 +160,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,
@@ -203,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::<Value>().expect("request json"))
.collect::<Vec<_>>();
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(())
@@ -247,10 +207,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"),
]);
@@ -260,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();
@@ -295,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::<Value>().expect("request json"))
.collect::<Vec<_>>();
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:?}"
@@ -353,10 +301,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"),
]);
@@ -366,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();
@@ -407,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::<Value>().expect("request json"))
.collect::<Vec<_>>();
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::<Value>(output_text) {
let exit_code = exec_output["metadata"]["exit_code"]
@@ -482,10 +418,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"),
]);
@@ -495,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();
@@ -516,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::<Value>().expect("request json"))
.collect::<Vec<_>>();
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"),
+129 -177
View File
@@ -9,10 +9,13 @@ 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;
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;
@@ -20,9 +23,9 @@ 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;
async fn submit_turn(
test: &TestCodex,
@@ -55,27 +58,6 @@ async fn submit_turn(
Ok(())
}
fn request_bodies(requests: &[Request]) -> Result<Vec<Value>> {
requests
.iter()
.map(|req| Ok(serde_json::from_slice::<Value>(&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<String> {
body.get("tools")
.and_then(Value::as_array)
@@ -104,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![
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"),
]),
)
.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,
@@ -125,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)
@@ -167,9 +148,10 @@ async fn shell_escalated_permissions_rejected_then_ok() -> Result<()> {
"timeout_ms": 1_000,
});
let responses = vec![
mount_sse_once(
&server,
sse(vec![
json!({"type": "response.created", "response": {"id": "resp-1"}}),
ev_response_created("resp-1"),
ev_function_call(
call_id_blocked,
"shell",
@@ -177,8 +159,12 @@ async fn shell_escalated_permissions_rejected_then_ok() -> Result<()> {
),
ev_completed("resp-1"),
]),
)
.await;
let second_mock = mount_sse_once(
&server,
sse(vec![
json!({"type": "response.created", "response": {"id": "resp-2"}}),
ev_response_created("resp-2"),
ev_function_call(
call_id_success,
"shell",
@@ -186,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,
@@ -201,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")
@@ -253,10 +220,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(())
}
@@ -281,18 +246,23 @@ async fn local_shell_missing_ids_maps_to_function_output_error() -> Result<()> {
}
});
let responses = vec![
mount_sse_once(
&server,
sse(vec![
json!({"type": "response.created", "response": {"id": "resp-1"}}),
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,
@@ -302,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),
@@ -324,11 +286,11 @@ async fn collect_tools(use_unified_exec: bool) -> Result<Vec<String>> {
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"),
])];
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;
@@ -343,15 +305,8 @@ async fn collect_tools(use_unified_exec: bool) -> Result<Vec<String>> {
)
.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)]
@@ -391,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![
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"),
]),
)
.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,
@@ -412,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")
@@ -436,15 +390,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::<u64>().ok())
let timeout_pattern = r"(?s)^Total output lines: \d+
command timed out after (?P<ms>\d+) milliseconds
line
.*$";
let captures = assert_regex_match(timeout_pattern, stdout);
let duration_ms = captures
.name("ms")
.and_then(|m| m.as_str().parse::<u64>().ok())
.unwrap_or_default();
assert!(
duration_ms >= timeout_ms,
@@ -452,14 +406,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(())
@@ -483,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![
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"),
]),
)
.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,
@@ -504,43 +457,31 @@ 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")
.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:
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(())
}
@@ -569,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![
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"),
]),
)
.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,
@@ -590,23 +536,29 @@ 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")
.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:
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(())
+5 -4
View File
@@ -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",
+20 -79
View File
@@ -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;
@@ -21,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)
@@ -58,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(()));
@@ -89,10 +74,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"),
]);
@@ -102,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();
@@ -139,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::<Value>().expect("request json"))
.collect::<Vec<_>>();
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)
@@ -202,10 +173,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"),
]);
@@ -215,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();
@@ -236,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::<Value>().expect("request json"))
.collect::<Vec<_>>();
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"
);
@@ -282,10 +238,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"),
]);
@@ -295,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();
@@ -316,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::<Value>().expect("request json"))
.collect::<Vec<_>>();
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),
@@ -338,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"
);
+2 -2
View File
@@ -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`)
+7 -11
View File
@@ -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")
+4 -11
View File
@@ -24,14 +24,11 @@ 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"),
]);
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")
@@ -46,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")
+1
View File
@@ -17,4 +17,5 @@ walkdir = "2"
workspace = true
[dev-dependencies]
assert_matches = { workspace = true }
pretty_assertions = "1.4.1"
+3 -2
View File
@@ -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]
+1
View File
@@ -28,3 +28,4 @@ tracing = { workspace = true, features = ["log"] }
wiremock = { workspace = true }
[dev-dependencies]
assert_matches = { workspace = true }
+13 -20
View File
@@ -30,19 +30,21 @@ pub(crate) fn pull_events_from_value(value: &JsonValue) -> Vec<PullEvent> {
#[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)
);
}
}
+8
View File
@@ -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<u64>,
}
@@ -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<u64>,
/// Seconds until the window resets.
#[ts(type = "number | null")]
pub resets_in_seconds: Option<u64>,
}
+4 -4
View File
@@ -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 <key>` 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": <u16> }`.
- 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 <PORT>] [--server-info <FILE>] [--http-shutdow
```
- `--port <PORT>`: Port to bind on `127.0.0.1`. If omitted, an ephemeral port is chosen.
- `--server-info <FILE>`: If set, the proxy writes a single line of JSON with `{ "port": <PORT> }` once listening.
- `--server-info <FILE>`: If set, the proxy writes a single line of JSON with `{ "port": <PORT>, "pid": <PID> }` once listening.
- `--http-shutdown`: If set, enables `GET /shutdown` to exit the process with code `0`.
## Notes
@@ -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<F>(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<usize> {
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::<c_void>(),
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<F>(mut read_fn: F) -> Result<&'static str>
where
F: FnOnce(&mut [u8]) -> std::io::Result<usize>,
F: FnMut(&mut [u8]) -> std::io::Result<usize>,
{
// 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();
@@ -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");
+11 -8
View File
@@ -120,14 +120,17 @@ impl RmcpClient {
url: &str,
bearer_token: Option<String>,
) -> Result<Self> {
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);
+1
View File
@@ -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 }
@@ -2,5 +2,5 @@
source: tui/src/chatwidget/tests.rs
expression: blob1
---
⠋ Exploring
• Exploring
└ List ls -la
@@ -2,6 +2,6 @@
source: tui/src/chatwidget/tests.rs
expression: blob3
---
⠋ Exploring
• Exploring
└ List ls -la
Read foo.txt
+4 -9
View File
@@ -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:?}"),
}
+4 -6
View File
@@ -116,12 +116,10 @@ pub(crate) fn output_lines(
}
pub(crate) fn spinner(start_time: Option<Instant>) -> 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 {
+20 -22
View File
@@ -19,14 +19,13 @@ 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.";
Instructions for how to install and run Codex in WSL:
https://developers.openai.com/codex/windows";
pub(crate) struct WindowsSetupWidget {
pub codex_home: PathBuf,
@@ -47,7 +46,7 @@ impl WindowsSetupWidget {
Self {
codex_home,
selection: None,
highlighted: WindowsSetupSelection::Continue,
highlighted: WindowsSetupSelection::Install,
error: None,
exit_requested: false,
}
@@ -83,11 +82,10 @@ impl WindowsSetupWidget {
impl WidgetRef for &WindowsSetupWidget {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let mut lines: Vec<Line> = 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 +100,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 +131,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(),
},
_ => {}
}
@@ -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})
+16 -5
View File
@@ -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(),
+1
View File
@@ -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]
+2 -1
View File
@@ -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);
}
}
+1 -9
View File
@@ -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
+36 -8
View File
@@ -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 <token>` 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 = "<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`:
+7 -7
View File
@@ -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}}
```
+5 -1
View File
@@ -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]...
```
+20
View File
@@ -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: {}
+56 -26
View File
@@ -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,96 @@ 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
### Structured output
If you don't have the original `Thread` instance to continue the thread, you can resume by calling `resumeThread()` and providing the thread identifier.
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 threadId = "...";
const thread = codex.resumeThread(threadId);
const result = await thread.run("Implement the fix");
const schema = {
type: "object",
properties: {
summary: { type: "string" },
status: { type: "string", enum: ["ok", "action_required"] },
},
required: ["summary", "status"],
additionalProperties: false,
} as const;
console.log(result);
const turn = await thread.run("Summarize repository status", { outputSchema: schema });
console.log(turn.finalResponse);
```
### Working directory
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.
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 thread = codex.startThread({
workingDirectory: "/path/to/working/directory",
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);
```
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.
### 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.
```typescript
const savedThreadId = process.env.CODEX_THREAD_ID!;
const thread = codex.resumeThread(savedThreadId);
await thread.run("Implement the fix");
```
### Working directory controls
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,
});
```
+3 -1
View File
@@ -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"
}
}
+2 -6
View File
@@ -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 });
+6
View File
@@ -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");
}
+22
View File
@@ -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);
+19
View File
@@ -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);
+8 -3
View File
@@ -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);
}
+1
View File
@@ -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";
+41
View File
@@ -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<void>;
};
export async function createOutputSchemaFile(schema: unknown): Promise<OutputSchemaFile> {
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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+26 -15
View File
@@ -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<StreamedTurn> {
return { events: this.runStreamedInternal(input) };
async runStreamed(input: string, turnOptions: TurnOptions = {}): Promise<StreamedTurn> {
return { events: this.runStreamedInternal(input, turnOptions) };
}
private async *runStreamedInternal(input: string): AsyncGenerator<ThreadEvent> {
private async *runStreamedInternal(
input: string,
turnOptions: TurnOptions = {},
): AsyncGenerator<ThreadEvent> {
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<Turn> {
const generator = this.runStreamedInternal(input);
async run(input: string, turnOptions: TurnOptions = {}): Promise<Turn> {
const generator = this.runStreamedInternal(input, turnOptions);
const items: ThreadItem[] = [];
let finalResponse: string = "";
let usage: Usage | null = null;
+4
View File
@@ -0,0 +1,4 @@
export type TurnOptions = {
/** JSON schema describing the expected agent output. */
outputSchema?: unknown;
};
+3
View File
@@ -46,6 +46,9 @@ export type ResponsesApiRequest = {
role: string;
content?: Array<{ type: string; text: string }>;
}>;
text?: {
format?: Record<string, unknown>;
};
};
export type RecordedRequest = {
+57
View File
@@ -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,
+44
View File
@@ -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<ThreadEvent>): Promise<void> {