Merge remote-tracking branch 'origin/main' into codex/dynamic-tool-schema-errors

This commit is contained in:
Alex Kotliarskyi
2026-05-22 18:16:05 -07:00
61 changed files with 2995 additions and 417 deletions
+5
View File
@@ -7,6 +7,11 @@
"format": "tar.gz",
"path": "codex-zsh/bin/zsh"
},
"macos-x86_64": {
"name": "codex-zsh-x86_64-apple-darwin.tar.gz",
"format": "tar.gz",
"path": "codex-zsh/bin/zsh"
},
"linux-x86_64": {
"name": "codex-zsh-x86_64-unknown-linux-musl.tar.gz",
"format": "tar.gz",
+5
View File
@@ -28,8 +28,13 @@ jobs:
- uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0
with:
components: rustfmt
- uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49
with:
tool: just
- name: cargo fmt
run: cargo fmt -- --config imports_granularity=Item --check
- name: Rust benchmark smoke test
run: just bench-smoke
cargo_shear:
name: cargo shear
+5
View File
@@ -70,8 +70,13 @@ jobs:
- uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0
with:
components: rustfmt
- uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49
with:
tool: just
- name: cargo fmt
run: cargo fmt -- --config imports_granularity=Item --check
- name: Rust benchmark smoke test
run: just bench-smoke
cargo_shear:
name: cargo shear
+4
View File
@@ -69,6 +69,10 @@ jobs:
fail-fast: false
matrix:
include:
- runner: macos-15-large
target: x86_64-apple-darwin
variant: macos-15
archive_name: codex-zsh-x86_64-apple-darwin.tar.gz
- runner: macos-15-xlarge
target: aarch64-apple-darwin
variant: macos-15
+1
View File
@@ -30,6 +30,7 @@ In the codex-rs folder where the rust code lives:
- Prefer private modules and explicitly exported public crate API.
- If you change `ConfigToml` or nested config types, run `just write-config-schema` to update `codex-rs/core/config.schema.json`.
- When working with MCP tool calls, prefer using `codex-rs/codex-mcp/src/mcp_connection_manager.rs` to handle mutation of tools and tool calls. Aim to minimize the footprint of changes and leverage existing abstractions rather than plumbing code through multiple levels of function calls.
- Do not call `reset_client_session` unnecessarily; let the incremental check logic decide whether to reuse the previous request.
- If you change Rust dependencies (`Cargo.toml` or `Cargo.lock`), run `just bazel-lock-update` from the
repo root to refresh `MODULE.bazel.lock`, and include that lockfile update in the same change.
- After dependency changes, run `just bazel-lock-check` from the repo root so lockfile drift is caught
+3
View File
@@ -768,6 +768,7 @@
"compact_str_0.8.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"borsh\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"castaway\",\"req\":\"^0.2.3\"},{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"cfg-if\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"diesel\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"itoa\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"markup\",\"optional\":true,\"req\":\"^0.13\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"proptest\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"size_32\"],\"name\":\"rkyv\",\"optional\":true,\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"alloc\",\"size_32\"],\"kind\":\"dev\",\"name\":\"rkyv\",\"req\":\"^0.7\"},{\"name\":\"rustversion\",\"req\":\"^1\"},{\"name\":\"ryu\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"features\":[\"union\"],\"name\":\"smallvec\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"sqlx\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"static_assertions\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"test-case\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"test-strategy\",\"req\":\"^0.3\"}],\"features\":{\"arbitrary\":[\"dep:arbitrary\"],\"borsh\":[\"dep:borsh\"],\"bytes\":[\"dep:bytes\"],\"default\":[\"std\"],\"diesel\":[\"dep:diesel\"],\"markup\":[\"dep:markup\"],\"proptest\":[\"dep:proptest\"],\"quickcheck\":[\"dep:quickcheck\"],\"rkyv\":[\"dep:rkyv\"],\"serde\":[\"dep:serde\"],\"smallvec\":[\"dep:smallvec\"],\"sqlx\":[\"dep:sqlx\",\"std\"],\"sqlx-mysql\":[\"sqlx\",\"sqlx/mysql\"],\"sqlx-postgres\":[\"sqlx\",\"sqlx/postgres\"],\"sqlx-sqlite\":[\"sqlx\",\"sqlx/sqlite\"],\"std\":[]}}",
"compiletest_rs_0.11.2": "{\"dependencies\":[{\"name\":\"diff\",\"req\":\"^0.1.10\"},{\"name\":\"filetime\",\"req\":\"^0.2\"},{\"name\":\"getopts\",\"req\":\"^0.2\"},{\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"miow\",\"req\":\"^0.6\",\"target\":\"cfg(windows)\"},{\"name\":\"regex\",\"req\":\"^1.0\"},{\"name\":\"rustfix\",\"req\":\"^0.8\"},{\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_derive\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"tempfile\",\"optional\":true,\"req\":\"^3.0\"},{\"name\":\"tester\",\"req\":\"^0.9\"},{\"features\":[\"Win32\"],\"name\":\"windows-sys\",\"req\":\"^0.59\",\"target\":\"cfg(windows)\"}],\"features\":{\"rustc\":[],\"stable\":[],\"tmp\":[\"tempfile\"]}}",
"concurrent-queue_2.5.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"cargo_bench_support\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"crossbeam-utils\",\"req\":\"^0.8.11\"},{\"kind\":\"dev\",\"name\":\"easy-parallel\",\"req\":\"^3.1.0\"},{\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^2.0.0\"},{\"name\":\"loom\",\"optional\":true,\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"default_features\":false,\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}",
"condtype_1.3.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"cfg-if\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.141\"}],\"features\":{}}",
"console_0.15.11": "{\"dependencies\":[{\"name\":\"encode_unicode\",\"req\":\"^1\",\"target\":\"cfg(windows)\"},{\"name\":\"libc\",\"req\":\"^0.2.99\"},{\"name\":\"once_cell\",\"req\":\"^1.8\"},{\"default_features\":false,\"features\":[\"std\",\"bit-set\",\"break-dead-code\"],\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.4.2\"},{\"name\":\"unicode-width\",\"optional\":true,\"req\":\"^0.2\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Console\",\"Win32_Storage_FileSystem\",\"Win32_UI_Input_KeyboardAndMouse\"],\"name\":\"windows-sys\",\"req\":\"^0.59\",\"target\":\"cfg(windows)\"}],\"features\":{\"ansi-parsing\":[],\"default\":[\"unicode-width\",\"ansi-parsing\"],\"windows-console-colors\":[\"ansi-parsing\"]}}",
"const-hex_1.17.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"cpufeatures\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_arch = \\\"x86\\\", target_arch = \\\"x86_64\\\"))\"},{\"kind\":\"dev\",\"name\":\"divan\",\"package\":\"codspeed-divan-compat\",\"req\":\"^3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"faster-hex\",\"req\":\"^0.10.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"~0.4.2\"},{\"default_features\":false,\"name\":\"proptest\",\"optional\":true,\"req\":\"^1.4\"},{\"kind\":\"dev\",\"name\":\"rustc-hex\",\"req\":\"^2.1\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"__fuzzing\":[\"dep:proptest\",\"std\"],\"alloc\":[\"serde_core?/alloc\",\"proptest?/alloc\"],\"core-error\":[],\"default\":[\"std\"],\"force-generic\":[],\"hex\":[],\"nightly\":[],\"portable-simd\":[],\"serde\":[\"dep:serde_core\"],\"std\":[\"serde_core?/std\",\"proptest?/std\",\"alloc\"]}}",
"const-oid_0.9.6": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.2\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3\"}],\"features\":{\"db\":[],\"std\":[]}}",
@@ -859,6 +860,8 @@
"dispatch2_0.3.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\"],\"name\":\"bitflags\",\"req\":\"^2.5.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"block2\",\"optional\":true,\"req\":\">=0.6.1, <0.8.0\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.80\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"objc2\",\"optional\":true,\"req\":\">=0.6.1, <0.8.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"}],\"features\":{\"alloc\":[],\"block2\":[\"dep:block2\"],\"default\":[\"std\",\"block2\",\"libc\",\"objc2\"],\"libc\":[\"dep:libc\"],\"objc2\":[\"dep:objc2\"],\"std\":[\"alloc\"]}}",
"display_container_0.9.0": "{\"dependencies\":[{\"name\":\"either\",\"req\":\"^1.8\"},{\"name\":\"indenter\",\"req\":\"^0.3.3\"}],\"features\":{}}",
"displaydoc_0.2.5": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^0.6.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1\"},{\"name\":\"syn\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^1.0.24\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}",
"divan-macros_0.1.21": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"full\",\"clone-impls\",\"parsing\",\"printing\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^2.0.18\"}],\"features\":{}}",
"divan_0.1.21": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\",\"env\"],\"name\":\"clap\",\"req\":\"^4\"},{\"name\":\"condtype\",\"req\":\"^1.3\"},{\"name\":\"divan-macros\",\"req\":\"=0.1.21\"},{\"name\":\"libc\",\"req\":\"^0.2.148\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"mimalloc\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"std\",\"string\"],\"name\":\"regex\",\"package\":\"regex-lite\",\"req\":\"^0.1\"}],\"features\":{\"default\":[\"wrap_help\"],\"dyn_thread_local\":[],\"help\":[\"clap/help\"],\"internal_benches\":[],\"wrap_help\":[\"help\",\"clap/wrap_help\"]}}",
"dns-lookup_3.0.1": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"name\":\"socket2\",\"req\":\"^0.6.0\"},{\"features\":[\"Win32_Networking_WinSock\",\"Win32_Foundation\"],\"name\":\"windows-sys\",\"req\":\"^0.60\",\"target\":\"cfg(windows)\"}],\"features\":{}}",
"document-features_0.2.12": "{\"dependencies\":[{\"name\":\"litrs\",\"req\":\"^1.0.0\"}],\"features\":{\"default\":[],\"self-test\":[]}}",
"dotenvy_0.15.7": "{\"dependencies\":[{\"name\":\"clap\",\"optional\":true,\"req\":\"^3.2\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.16.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.3.0\"}],\"features\":{\"cli\":[\"clap\"]}}",
+35 -1
View File
@@ -2507,6 +2507,7 @@ dependencies = [
"codex-feedback",
"codex-git-utils",
"codex-hooks",
"codex-install-context",
"codex-login",
"codex-mcp",
"codex-memories-read",
@@ -3770,6 +3771,7 @@ dependencies = [
"codex-features",
"codex-protocol",
"codex-utils-absolute-path",
"codex-utils-cargo-bin",
"codex-utils-output-truncation",
"codex-utils-pty",
"codex-utils-string",
@@ -3981,6 +3983,7 @@ version = "0.0.0"
dependencies = [
"base64 0.22.1",
"codex-utils-cache",
"divan",
"image",
"mime_guess",
"thiserror 2.0.18",
@@ -4223,6 +4226,12 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "condtype"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf0a07a401f374238ab8e2f11a104d2851bf9ce711ec69804834de8af45c7af"
[[package]]
name = "console"
version = "0.15.11"
@@ -5219,6 +5228,31 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "divan"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a405457ec78b8fe08b0e32b4a3570ab5dff6dd16eb9e76a5ee0a9d9cbd898933"
dependencies = [
"cfg-if",
"clap",
"condtype",
"divan-macros",
"libc",
"regex-lite",
]
[[package]]
name = "divan-macros"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9556bc800956545d6420a640173e5ba7dfa82f38d3ea5a167eb555bc69ac3323"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "dns-lookup"
version = "3.0.1"
@@ -5492,7 +5526,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
+1
View File
@@ -275,6 +275,7 @@ deno_core_icudata = "0.77.0"
derive_more = "2"
diffy = "0.4.2"
dirs = "6"
divan = "0.1.21"
dns-lookup = "3.0.1"
dotenvy = "0.15.7"
dunce = "1.0.4"
@@ -64,6 +64,8 @@ const REMOTE_CONTROL_WEBSOCKET_PONG_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(60);
const REMOTE_CONTROL_ACCOUNT_ID_RETRY_INTERVAL: std::time::Duration =
std::time::Duration::from_secs(1);
const REMOTE_CONTROL_RECONNECT_BACKOFF_CAP: std::time::Duration =
std::time::Duration::from_secs(30);
struct BoundedOutboundBuffer {
buffer_by_stream: HashMap<(ClientId, StreamId), VecDeque<ServerEnvelope>>,
@@ -514,12 +516,23 @@ impl RemoteControlWebsocket {
} else {
self.status_publisher
.publish_status(RemoteControlConnectionStatus::Errored);
let reconnect_attempt = self.reconnect_attempt.saturating_add(1);
let (reconnect_delay, reconnect_backoff_reset) =
next_reconnect_delay(&mut self.reconnect_attempt);
warn!(
"failed to connect to app-server remote control websocket: {}, err: {}",
remote_control_target.websocket_url, err
websocket_url = %remote_control_target.websocket_url,
error = %err,
reconnect_attempt,
reconnect_delay = ?reconnect_delay,
reconnect_backoff_reset,
"failed to connect to app-server remote control websocket"
);
let reconnect_delay = backoff(self.reconnect_attempt);
self.reconnect_attempt += 1;
if reconnect_backoff_reset {
info!(
reconnect_backoff_cap = ?REMOTE_CONTROL_RECONNECT_BACKOFF_CAP,
"reset app-server remote control websocket reconnect backoff after cap"
);
}
reconnect_delay
};
tokio::select! {
@@ -1036,6 +1049,17 @@ pub(crate) async fn load_remote_control_auth(
})
}
fn next_reconnect_delay(reconnect_attempt: &mut u64) -> (std::time::Duration, bool) {
let reconnect_delay = backoff(*reconnect_attempt).min(REMOTE_CONTROL_RECONNECT_BACKOFF_CAP);
let reconnect_backoff_reset = reconnect_delay == REMOTE_CONTROL_RECONNECT_BACKOFF_CAP;
*reconnect_attempt = if reconnect_backoff_reset {
0
} else {
(*reconnect_attempt).saturating_add(1)
};
(reconnect_delay, reconnect_backoff_reset)
}
pub(super) async fn connect_remote_control_websocket(
remote_control_target: &RemoteControlTarget,
state_db: Option<&StateRuntime>,
@@ -1326,6 +1350,26 @@ mod tests {
const TEST_HTTP_ACCEPT_TIMEOUT: Duration = Duration::from_secs(5);
const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111";
#[test]
fn next_reconnect_delay_resets_after_cap() {
let mut reconnect_attempt = 9;
let (reconnect_delay, reconnect_backoff_reset) =
next_reconnect_delay(&mut reconnect_attempt);
assert_eq!(reconnect_delay, REMOTE_CONTROL_RECONNECT_BACKOFF_CAP);
assert!(reconnect_backoff_reset);
assert_eq!(reconnect_attempt, 0);
let (reconnect_delay, reconnect_backoff_reset) =
next_reconnect_delay(&mut reconnect_attempt);
assert!(reconnect_delay >= Duration::from_millis(180));
assert!(reconnect_delay <= Duration::from_millis(220));
assert!(!reconnect_backoff_reset);
assert_eq!(reconnect_attempt, 1);
}
fn remote_control_status_channel() -> (
RemoteControlStatusPublisher,
watch::Receiver<RemoteControlStatusChangedNotification>,
@@ -173,6 +173,20 @@ impl McpProcess {
.await
}
pub async fn new_with_program_and_env(
codex_home: &Path,
program: &Path,
env_overrides: &[(&str, Option<&str>)],
) -> anyhow::Result<Self> {
Self::new_with_program_env_and_args(
codex_home,
program,
env_overrides,
&[DISABLE_PLUGIN_STARTUP_TASKS_ARG],
)
.await
}
async fn new_with_env_and_args(
codex_home: &Path,
env_overrides: &[(&str, Option<&str>)],
@@ -180,6 +194,15 @@ impl McpProcess {
) -> anyhow::Result<Self> {
let program = codex_utils_cargo_bin::cargo_bin("codex-app-server")
.context("should find binary for codex-app-server")?;
Self::new_with_program_env_and_args(codex_home, &program, env_overrides, args).await
}
async fn new_with_program_env_and_args(
codex_home: &Path,
program: &Path,
env_overrides: &[(&str, Option<&str>)],
args: &[&str],
) -> anyhow::Result<Self> {
let mut cmd = Command::new(program);
cmd.stdin(Stdio::piped());
@@ -37,6 +37,7 @@ use core_test_support::skip_if_no_network;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
use std::path::Path;
use std::path::PathBuf;
use tempfile::TempDir;
use tokio::time::timeout;
@@ -94,10 +95,9 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> {
(Feature::UnifiedExec, false),
(Feature::ShellSnapshot, false),
]),
&zsh_path,
)?;
let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace).await?;
let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace, &zsh_path).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let start_id = mcp
@@ -162,7 +162,7 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> {
};
assert_eq!(id, "call-zsh-fork");
assert_eq!(status, CommandExecutionStatus::InProgress);
assert!(command.starts_with(&zsh_path.display().to_string()));
assert!(command.starts_with(&command_packaged_zsh_path(&codex_home).display().to_string()));
assert!(command.contains("/bin/sh -c"));
assert!(command.contains("sleep 0.01"));
assert!(command.contains(&release_marker.display().to_string()));
@@ -213,10 +213,9 @@ async fn turn_start_shell_zsh_fork_exec_approval_decline_v2() -> Result<()> {
(Feature::UnifiedExec, false),
(Feature::ShellSnapshot, false),
]),
&zsh_path,
)?;
let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace).await?;
let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace, &zsh_path).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let start_id = mcp
@@ -346,10 +345,9 @@ async fn turn_start_shell_zsh_fork_exec_approval_cancel_v2() -> Result<()> {
(Feature::UnifiedExec, false),
(Feature::ShellSnapshot, false),
]),
&zsh_path,
)?;
let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace).await?;
let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace, &zsh_path).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let start_id = mcp
@@ -505,10 +503,9 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
(Feature::UnifiedExec, false),
(Feature::ShellSnapshot, false),
]),
&zsh_path,
)?;
let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace).await?;
let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace, &zsh_path).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let start_id = mcp
@@ -603,7 +600,8 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
);
approved_subcommand_strings.push(approval_command.to_string());
}
let is_parent_approval = approval_command.contains(&zsh_path.display().to_string())
let is_parent_approval = approval_command
.contains(&command_packaged_zsh_path(&codex_home).display().to_string())
&& (approval_command.contains(&shell_command)
|| (has_first_file && has_second_file)
|| approval_command.contains(&parent_shell_hint));
@@ -738,9 +736,58 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
Ok(())
}
async fn create_zsh_test_mcp_process(codex_home: &Path, zdotdir: &Path) -> Result<McpProcess> {
async fn create_zsh_test_mcp_process(
codex_home: &Path,
zdotdir: &Path,
zsh_path: &Path,
) -> Result<McpProcess> {
let app_server = create_test_package_app_server(codex_home, zsh_path)?;
let zdotdir = zdotdir.to_string_lossy().into_owned();
McpProcess::new_with_env(codex_home, &[("ZDOTDIR", Some(zdotdir.as_str()))]).await
McpProcess::new_with_program_and_env(
codex_home,
&app_server,
&[("ZDOTDIR", Some(zdotdir.as_str()))],
)
.await
}
fn create_test_package_app_server(codex_home: &Path, zsh_path: &Path) -> Result<PathBuf> {
let package_dir = codex_home.join("test-package");
let bin_dir = package_dir.join("bin");
let package_zsh_path = packaged_zsh_path(codex_home);
let Some(zsh_bin_dir) = package_zsh_path.parent() else {
anyhow::bail!("packaged zsh path should have parent");
};
std::fs::create_dir_all(&bin_dir)?;
std::fs::create_dir_all(zsh_bin_dir)?;
std::fs::write(package_dir.join("codex-package.json"), "{}")?;
let app_server = bin_dir.join("codex-app-server");
copy_with_permissions(
&codex_utils_cargo_bin::cargo_bin("codex-app-server")?,
&app_server,
)?;
copy_with_permissions(zsh_path, &package_zsh_path)?;
Ok(app_server)
}
fn packaged_zsh_path(codex_home: &Path) -> PathBuf {
codex_home
.join("test-package")
.join("codex-resources")
.join("zsh")
.join("bin")
.join("zsh")
}
fn command_packaged_zsh_path(codex_home: &Path) -> PathBuf {
let path = packaged_zsh_path(codex_home);
std::fs::canonicalize(&path).unwrap_or(path)
}
fn copy_with_permissions(source: &Path, destination: &Path) -> std::io::Result<()> {
std::fs::copy(source, destination)?;
std::fs::set_permissions(destination, std::fs::metadata(source)?.permissions())
}
fn create_config_toml(
@@ -748,7 +795,6 @@ fn create_config_toml(
server_uri: &str,
approval_policy: &str,
feature_flags: &BTreeMap<Feature, bool>,
zsh_path: &Path,
) -> std::io::Result<()> {
let mut features = BTreeMap::from([(Feature::RemoteModels, false)]);
for (feature, enabled) in feature_flags {
@@ -774,7 +820,6 @@ fn create_config_toml(
model = "mock-model"
approval_policy = "{approval_policy}"
sandbox_mode = "read-only"
zsh_path = "{zsh_path}"
model_provider = "mock_provider"
@@ -787,9 +832,7 @@ base_url = "{server_uri}/v1"
wire_api = "responses"
request_max_retries = 0
stream_max_retries = 0
"#,
approval_policy = approval_policy,
zsh_path = zsh_path.display()
"#
),
)
}
+4
View File
@@ -2,6 +2,7 @@ use crate::TransportError;
use crate::error::ApiError;
use crate::rate_limits::parse_promo_message;
use crate::rate_limits::parse_rate_limit_for_limit;
use crate::rate_limits::parse_rate_limit_reached_type;
use base64::Engine;
use chrono::DateTime;
use chrono::Utc;
@@ -85,6 +86,8 @@ pub fn map_api_error(err: ApiError) -> CodexErr {
parse_rate_limit_for_limit(map, limit_id.as_deref())
});
let promo_message = headers.as_ref().and_then(parse_promo_message);
let rate_limit_reached_type =
headers.as_ref().and_then(parse_rate_limit_reached_type);
let resets_at = err
.error
.resets_at
@@ -94,6 +97,7 @@ pub fn map_api_error(err: ApiError) -> CodexErr {
resets_at,
rate_limits: rate_limits.map(Box::new),
promo_message,
rate_limit_reached_type,
});
} else if err.error.error_type.as_deref() == Some("usage_not_included") {
return CodexErr::UsageNotIncluded;
@@ -194,6 +194,37 @@ fn map_api_error_does_not_fallback_limit_name_to_limit_id() {
);
}
#[test]
fn map_api_error_ignores_unparseable_rate_limit_reached_type_headers() {
let values = [
http::HeaderValue::from_static("future_rate_limit_reached_type"),
http::HeaderValue::from_bytes(&[0xff]).expect("valid opaque header value"),
];
for value in values {
let mut headers = HeaderMap::new();
headers.insert("x-codex-rate-limit-reached-type", value);
let body = serde_json::json!({
"error": {
"type": "usage_limit_reached",
"plan_type": "pro",
}
})
.to_string();
let err = map_api_error(ApiError::Transport(TransportError::Http {
status: http::StatusCode::TOO_MANY_REQUESTS,
url: Some("http://example.com/v1/responses".to_string()),
headers: Some(headers),
body: Some(body),
}));
let CodexErr::UsageLimitReached(usage_limit) = err else {
panic!("expected CodexErr::UsageLimitReached, got {err:?}");
};
assert_eq!(usage_limit.rate_limit_reached_type, None);
}
}
#[test]
fn map_api_error_extracts_identity_auth_details_from_headers() {
let mut headers = HeaderMap::new();
+8
View File
@@ -1,5 +1,6 @@
use codex_protocol::account::PlanType;
use codex_protocol::protocol::CreditsSnapshot;
use codex_protocol::protocol::RateLimitReachedType;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow;
use http::HeaderMap;
@@ -178,6 +179,13 @@ pub fn parse_promo_message(headers: &HeaderMap) -> Option<String> {
.map(std::string::ToString::to_string)
}
pub(crate) fn parse_rate_limit_reached_type(headers: &HeaderMap) -> Option<RateLimitReachedType> {
parse_header_str(headers, "x-codex-rate-limit-reached-type")?
.trim()
.parse()
.ok()
}
fn parse_rate_limit_window(
headers: &HeaderMap,
used_percent_header: &str,
-3
View File
@@ -291,9 +291,6 @@ pub struct ConfigToml {
#[schemars(skip)]
pub js_repl_node_module_dirs: Option<Vec<AbsolutePathBuf>>,
/// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution.
pub zsh_path: Option<AbsolutePathBuf>,
/// Profile to use from the `profiles` map.
pub profile: Option<String>,
-2
View File
@@ -48,8 +48,6 @@ pub struct ConfigProfile {
/// Deprecated: ignored.
#[schemars(skip)]
pub js_repl_node_module_dirs: Option<Vec<AbsolutePathBuf>>,
/// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution.
pub zsh_path: Option<AbsolutePathBuf>,
pub experimental_compact_prompt_file: Option<AbsolutePathBuf>,
pub include_permissions_instructions: Option<bool>,
pub include_apps_instructions: Option<bool>,
+1
View File
@@ -47,6 +47,7 @@ codex-shell-command = { workspace = true }
codex-execpolicy = { workspace = true }
codex-git-utils = { workspace = true }
codex-hooks = { workspace = true }
codex-install-context = { workspace = true }
codex-network-proxy = { workspace = true }
codex-otel = { workspace = true }
codex-plugin = { workspace = true }
-16
View File
@@ -711,14 +711,6 @@
}
],
"default": null
},
"zsh_path": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution."
}
},
"type": "object"
@@ -4986,14 +4978,6 @@
],
"default": null,
"description": "Windows-specific configuration."
},
"zsh_path": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution."
}
},
"title": "ConfigToml",
+1 -1
View File
@@ -938,7 +938,7 @@ impl Drop for ModelClientSession {
}
impl ModelClientSession {
pub(crate) fn reset_websocket_session(&mut self) {
fn reset_websocket_session(&mut self) {
self.websocket_session.connection = None;
self.websocket_session.last_request = None;
self.websocket_session.last_response_rx = None;
+19
View File
@@ -4470,6 +4470,25 @@ async fn add_dir_override_extends_workspace_writable_roots() -> std::io::Result<
Ok(())
}
#[tokio::test]
async fn default_zsh_path_sets_runtime_zsh_path() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let default_zsh_path = codex_home.path().join("packaged-zsh");
let config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides {
default_zsh_path: Some(default_zsh_path.abs()),
..Default::default()
},
codex_home.abs(),
)
.await?;
assert_eq!(config.zsh_path, Some(default_zsh_path));
Ok(())
}
#[tokio::test]
async fn sqlite_home_defaults_to_codex_home_for_workspace_write() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
+13 -3
View File
@@ -67,6 +67,7 @@ use codex_features::FeaturesToml;
use codex_features::MultiAgentV2ConfigToml;
use codex_features::NetworkProxyConfigToml;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_install_context::InstallContext;
use codex_login::AuthManagerConfig;
use codex_mcp::McpConfig;
use codex_memories_read::memory_root;
@@ -1392,11 +1393,18 @@ impl Config {
.effective_config()
.try_into()
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?;
let default_zsh_path = refreshed_config
.zsh_path
.clone()
.map(AbsolutePathBuf::try_from)
.transpose()?;
Self::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
cfg,
ConfigOverrides {
cwd: Some(self.cwd.to_path_buf()),
default_zsh_path,
..Default::default()
},
refreshed_config.codex_home.clone(),
@@ -2128,7 +2136,7 @@ pub struct ConfigOverrides {
pub codex_self_exe: Option<PathBuf>,
pub codex_linux_sandbox_exe: Option<PathBuf>,
pub main_execve_wrapper_exe: Option<PathBuf>,
pub zsh_path: Option<PathBuf>,
pub default_zsh_path: Option<AbsolutePathBuf>,
pub base_instructions: Option<String>,
pub developer_instructions: Option<String>,
pub personality: Option<Personality>,
@@ -2480,7 +2488,7 @@ impl Config {
codex_self_exe,
codex_linux_sandbox_exe,
main_execve_wrapper_exe,
zsh_path: zsh_path_override,
default_zsh_path,
base_instructions,
developer_instructions,
personality,
@@ -3199,7 +3207,9 @@ impl Config {
)
.await?;
let compact_prompt = compact_prompt.or(file_compact_prompt);
let zsh_path = zsh_path_override.or(cfg.zsh_path.map(Into::into));
let zsh_path = default_zsh_path
.or_else(|| InstallContext::current().bundled_zsh_path())
.map(AbsolutePathBuf::into_path_buf);
let review_model = override_review_model.or(cfg.review_model);
@@ -82,7 +82,7 @@ async fn restricted_read_implicitly_allows_helper_executables() -> std::io::Resu
},
ConfigOverrides {
cwd: Some(cwd.clone()),
zsh_path: Some(zsh_path.clone()),
default_zsh_path: Some(AbsolutePathBuf::try_from(zsh_path.clone())?),
main_execve_wrapper_exe: Some(execve_wrapper),
..Default::default()
},
+2 -10
View File
@@ -460,16 +460,8 @@ pub async fn reload_user_config(sess: &Arc<Session>) {
pub async fn compact(sess: &Arc<Session>, sub_id: String) {
let turn_context = sess.new_default_turn_with_sub_id(sub_id).await;
sess.spawn_task(
Arc::clone(&turn_context),
vec![UserInput::Text {
text: turn_context.compact_prompt().to_string(),
// Compaction prompt is synthesized; no UI element ranges to preserve.
text_elements: Vec::new(),
}],
CompactTask,
)
.await;
sess.spawn_task(Arc::clone(&turn_context), Vec::new(), CompactTask)
.await;
}
pub async fn thread_rollback(sess: &Arc<Session>, sub_id: String, num_turns: u32) {
+2 -2
View File
@@ -826,13 +826,13 @@ impl Session {
} else if use_zsh_fork_shell {
let zsh_path = config.zsh_path.as_ref().ok_or_else(|| {
anyhow::anyhow!(
"zsh fork feature enabled, but `zsh_path` is not configured; set `zsh_path` in config.toml"
"zsh fork feature enabled, but no packaged zsh fork is available for this install"
)
})?;
let zsh_path = zsh_path.to_path_buf();
shell::get_shell(shell::ShellType::Zsh, Some(&zsh_path)).ok_or_else(|| {
anyhow::anyhow!(
"zsh fork feature enabled, but zsh_path `{}` is not usable; set `zsh_path` to a valid zsh executable",
"zsh fork feature enabled, but packaged zsh fork `{}` is not usable",
zsh_path.display()
)
})?
+5 -5
View File
@@ -4319,7 +4319,7 @@ async fn absolute_cwd_update_with_turn_environment_is_allowed() {
}
#[tokio::test]
async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() {
let codex_home = tempfile::tempdir().expect("create temp dir");
let mut config = build_test_config(codex_home.path()).await;
config
@@ -4420,7 +4420,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
Err(err) => err,
};
let msg = format!("{err:#}");
assert!(msg.contains("zsh fork feature enabled, but `zsh_path` is not configured"));
assert!(msg.contains("zsh fork feature enabled, but no packaged zsh fork is available"));
}
// todo: use online model info
@@ -5738,7 +5738,7 @@ async fn spawn_task_turn_span_inherits_dispatch_trace_context() {
self: Arc<Self>,
_session: Arc<SessionTaskContext>,
_ctx: Arc<TurnContext>,
_input: Vec<UserInput>,
_input: Vec<TurnInput>,
_cancellation_token: CancellationToken,
) -> Option<String> {
let mut trace = self
@@ -7814,7 +7814,7 @@ impl SessionTask for NeverEndingTask {
self: Arc<Self>,
_session: Arc<SessionTaskContext>,
_ctx: Arc<TurnContext>,
_input: Vec<UserInput>,
_input: Vec<TurnInput>,
cancellation_token: CancellationToken,
) -> Option<String> {
if self.listen_to_cancellation_token {
@@ -7843,7 +7843,7 @@ impl SessionTask for GuardianDeniedApprovalTask {
self: Arc<Self>,
session: Arc<SessionTaskContext>,
ctx: Arc<TurnContext>,
_input: Vec<UserInput>,
_input: Vec<TurnInput>,
cancellation_token: CancellationToken,
) -> Option<String> {
let session = session.clone_session();
+91 -114
View File
@@ -114,8 +114,8 @@ use tracing::trace;
use tracing::trace_span;
use tracing::warn;
/// Takes a user message as input and runs a loop where, at each sampling request, the model
/// replies with either:
/// Takes initial turn input and runs a loop where, at each sampling request,
/// the model replies with either:
///
/// - requested function calls
/// - an assistant message
@@ -132,7 +132,7 @@ pub(crate) async fn run_turn(
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
turn_extension_data: Arc<codex_extension_api::ExtensionData>,
input: Vec<UserInput>,
input: Vec<TurnInput>,
prewarmed_client_session: Option<ModelClientSession>,
cancellation_token: CancellationToken,
) -> Option<String> {
@@ -142,25 +142,18 @@ pub(crate) async fn run_turn(
// new user message are recorded. Estimate pending incoming items (context
// diffs/full reinjection + user input) and trigger compaction preemptively
// when they would push the thread over the compaction threshold.
let pre_sampling_compact =
match run_pre_sampling_compact(&sess, &turn_context, &mut client_session).await {
Ok(pre_sampling_compact) => pre_sampling_compact,
Err(err) => {
if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded
&& let Err(err) = sess
.goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached {
turn_context: turn_context.as_ref(),
})
.await
{
warn!("failed to usage-limit active goal after usage-limit error: {err}");
}
error!("Failed to run pre-sampling compact");
return None;
}
};
if pre_sampling_compact.reset_client_session {
client_session.reset_websocket_session();
if let Err(err) = run_pre_sampling_compact(&sess, &turn_context, &mut client_session).await {
if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded
&& let Err(err) = sess
.goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached {
turn_context: turn_context.as_ref(),
})
.await
{
warn!("failed to usage-limit active goal after usage-limit error: {err}");
}
error!("Failed to run pre-sampling compact");
return None;
}
sess.record_context_updates_and_set_reference_context_item(turn_context.as_ref())
@@ -172,26 +165,9 @@ pub(crate) async fn run_turn(
if run_pending_session_start_hooks(&sess, &turn_context).await {
return None;
}
if !input.is_empty() {
let initial_turn_input = TurnInput::UserInput(input.clone());
let user_prompt_submit_outcome =
inspect_pending_input(&sess, &turn_context, &initial_turn_input).await;
if user_prompt_submit_outcome.should_stop {
record_additional_contexts(
&sess,
&turn_context,
user_prompt_submit_outcome.additional_contexts,
)
.await;
return None;
}
record_pending_input(
&sess,
&turn_context,
initial_turn_input,
user_prompt_submit_outcome.additional_contexts,
)
.await;
let mut can_drain_pending_input = input.is_empty();
if run_hooks_and_record_inputs(&sess, &turn_context, &input).await {
return None;
}
sess.merge_connector_selection(explicitly_enabled_connectors.clone())
@@ -232,9 +208,8 @@ pub(crate) async fn run_turn(
// one instance across retries within this turn.
// Pending input is drained into history before building the next model request.
// However, we defer that drain until after sampling in two cases:
// 1. At the start of a turn, so the fresh user prompt in `input` gets sampled first.
// 1. At the start of a turn, so the fresh turn input in `input` gets sampled first.
// 2. After auto-compact, when model/tool continuation needs to resume before any steer.
let mut can_drain_pending_input = input.is_empty();
loop {
// Note that pending_input would be something like a message the user
@@ -246,27 +221,7 @@ pub(crate) async fn run_turn(
Vec::new()
};
let mut blocked_pending_input = false;
let mut accepted_pending_input = false;
for pending_input_item in pending_input {
let hook_outcome =
inspect_pending_input(&sess, &turn_context, &pending_input_item).await;
if hook_outcome.should_stop {
blocked_pending_input = true;
record_additional_contexts(&sess, &turn_context, hook_outcome.additional_contexts)
.await;
} else {
accepted_pending_input = true;
record_pending_input(
&sess,
&turn_context,
pending_input_item,
hook_outcome.additional_contexts,
)
.await;
}
}
if blocked_pending_input && !accepted_pending_input {
if run_hooks_and_record_inputs(&sess, &turn_context, &pending_input).await {
break;
}
@@ -325,7 +280,7 @@ pub(crate) async fn run_turn(
// as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop.
if token_limit_reached && needs_follow_up {
let reset_client_session = match run_auto_compact(
if let Err(err) = run_auto_compact(
&sess,
&turn_context,
&mut client_session,
@@ -335,24 +290,18 @@ pub(crate) async fn run_turn(
)
.await
{
Ok(reset_client_session) => reset_client_session,
Err(err) => {
if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded
&& let Err(err) = sess
.goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached {
turn_context: turn_context.as_ref(),
})
.await
{
warn!(
"failed to usage-limit active goal after usage-limit error: {err}"
);
}
return None;
if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded
&& let Err(err) = sess
.goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached {
turn_context: turn_context.as_ref(),
})
.await
{
warn!(
"failed to usage-limit active goal after usage-limit error: {err}"
);
}
};
if reset_client_session {
client_session.reset_websocket_session();
return None;
}
can_drain_pending_input = !model_needs_follow_up;
continue;
@@ -450,6 +399,32 @@ pub(crate) async fn run_turn(
last_agent_message
}
async fn run_hooks_and_record_inputs(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
input: &[TurnInput],
) -> bool {
let mut blocked_input = false;
let mut accepted_input = false;
for input_item in input {
let hook_outcome = inspect_pending_input(sess, turn_context, input_item).await;
if hook_outcome.should_stop {
blocked_input = true;
record_additional_contexts(sess, turn_context, hook_outcome.additional_contexts).await;
} else {
accepted_input = true;
record_pending_input(
sess,
turn_context,
input_item.clone(),
hook_outcome.additional_contexts,
)
.await;
}
}
blocked_input && !accepted_input
}
#[expect(
clippy::await_holding_invalid_type,
reason = "MCP tool listing borrows the read guard across cancellation-aware await"
@@ -457,9 +432,18 @@ pub(crate) async fn run_turn(
async fn build_skills_and_plugins(
sess: &Arc<Session>,
turn_context: &TurnContext,
input: &[UserInput],
input: &[TurnInput],
cancellation_token: &CancellationToken,
) -> Option<(Vec<ResponseItem>, HashSet<String>)> {
let user_input = input
.iter()
.filter_map(|item| match item {
TurnInput::UserInput(content) => Some(content.as_slice()),
TurnInput::ResponseInputItem(_) => None,
})
.flatten()
.cloned()
.collect::<Vec<_>>();
let tracking = build_track_events_context(
turn_context.model_info.slug.clone(),
sess.conversation_id.to_string(),
@@ -473,7 +457,7 @@ async fn build_skills_and_plugins(
// Structured plugin:// mentions are resolved from the current session's
// enabled plugins, then converted into turn-scoped guidance below.
let mentioned_plugins =
collect_explicit_plugin_mentions(input, loaded_plugins.capability_summaries());
collect_explicit_plugin_mentions(&user_input, loaded_plugins.capability_summaries());
let mcp_tools = if turn_context.apps_enabled() || !mentioned_plugins.is_empty() {
// Plugin mentions need raw MCP/app inventory even when app tools
// are normally hidden so we can describe the plugin's currently
@@ -511,7 +495,7 @@ async fn build_skills_and_plugins(
let skill_name_counts_lower =
build_skill_name_counts(&skills_outcome.skills, &skills_outcome.disabled_paths).1;
let mentioned_skills = collect_explicit_skill_mentions(
input,
&user_input,
&skills_outcome.skills,
&skills_outcome.disabled_paths,
&connector_slug_counts,
@@ -553,7 +537,7 @@ async fn build_skills_and_plugins(
);
let plugin_items =
build_plugin_injections(&mentioned_plugins, &mcp_tools, &available_connectors);
let mut explicitly_enabled_connectors = collect_explicit_app_ids(input);
let mut explicitly_enabled_connectors = collect_explicit_app_ids(&user_input);
explicitly_enabled_connectors.extend(skill_connector_ids);
let connector_names_by_id = available_connectors
.iter()
@@ -589,7 +573,7 @@ async fn build_skills_and_plugins(
async fn track_turn_resolved_config_analytics(
sess: &Session,
turn_context: &TurnContext,
input: &[UserInput],
input: &[TurnInput],
) {
let thread_config = {
let state = sess.state.lock().await;
@@ -606,6 +590,11 @@ async fn track_turn_resolved_config_analytics(
thread_id: sess.conversation_id.to_string(),
num_input_images: input
.iter()
.filter_map(|item| match item {
TurnInput::UserInput(content) => Some(content.as_slice()),
TurnInput::ResponseInputItem(_) => None,
})
.flatten()
.filter(|item| {
matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })
})
@@ -634,10 +623,6 @@ async fn track_turn_resolved_config_analytics(
});
}
struct PreSamplingCompactResult {
reset_client_session: bool,
}
#[derive(Debug)]
struct AutoCompactTokenStatus {
// Full active context usage, independent of the configured auto-compact scope.
@@ -708,14 +693,12 @@ async fn run_pre_sampling_compact(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
client_session: &mut ModelClientSession,
) -> CodexResult<PreSamplingCompactResult> {
let mut pre_sampling_compacted =
maybe_run_previous_model_inline_compact(sess, turn_context, client_session).await?;
let mut reset_client_session = pre_sampling_compacted;
) -> CodexResult<()> {
maybe_run_previous_model_inline_compact(sess, turn_context, client_session).await?;
let token_status = auto_compact_token_status(sess.as_ref(), turn_context.as_ref()).await;
// Compact if the configured auto-compaction budget or usable context window is exhausted.
if token_status.token_limit_reached {
reset_client_session |= run_auto_compact(
run_auto_compact(
sess,
turn_context,
client_session,
@@ -724,26 +707,21 @@ async fn run_pre_sampling_compact(
CompactionPhase::PreTurn,
)
.await?;
pre_sampling_compacted = true;
}
Ok(PreSamplingCompactResult {
reset_client_session: pre_sampling_compacted && reset_client_session,
})
Ok(())
}
/// Runs pre-sampling compaction against the previous model when switching to a smaller
/// context-window model.
///
/// Returns `Ok(true)` when compaction ran successfully, `Ok(false)` when compaction was skipped
/// because the model/context-window preconditions were not met, and `Err(_)` only when compaction
/// was attempted and failed.
/// Returns `Err(_)` only when compaction was attempted and failed.
async fn maybe_run_previous_model_inline_compact(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
client_session: &mut ModelClientSession,
) -> CodexResult<bool> {
) -> CodexResult<()> {
let Some(previous_turn_settings) = sess.previous_turn_settings().await else {
return Ok(false);
return Ok(());
};
let previous_model_turn_context = Arc::new(
turn_context
@@ -752,10 +730,10 @@ async fn maybe_run_previous_model_inline_compact(
);
let Some(old_context_window) = previous_model_turn_context.model_context_window() else {
return Ok(false);
return Ok(());
};
let Some(new_context_window) = turn_context.model_context_window() else {
return Ok(false);
return Ok(());
};
let active_context_tokens = sess.get_total_token_usage().await;
let previous_model_limit_reached = match turn_context
@@ -776,7 +754,7 @@ async fn maybe_run_previous_model_inline_compact(
&& previous_model_turn_context.model_info.slug != turn_context.model_info.slug
&& old_context_window > new_context_window;
if should_run {
let _ = run_auto_compact(
run_auto_compact(
sess,
&previous_model_turn_context,
client_session,
@@ -785,9 +763,8 @@ async fn maybe_run_previous_model_inline_compact(
CompactionPhase::PreTurn,
)
.await?;
return Ok(true);
}
Ok(false)
Ok(())
}
async fn run_auto_compact(
@@ -797,7 +774,7 @@ async fn run_auto_compact(
initial_context_injection: InitialContextInjection,
reason: CompactionReason,
phase: CompactionPhase,
) -> CodexResult<bool> {
) -> CodexResult<()> {
if should_use_remote_compact_task(turn_context.provider.info()) {
if turn_context.features.enabled(Feature::RemoteCompactionV2) {
run_inline_remote_auto_compact_task_v2(
@@ -809,7 +786,7 @@ async fn run_auto_compact(
phase,
)
.await?;
return Ok(false);
return Ok(());
}
run_inline_remote_auto_compact_task(
Arc::clone(sess),
@@ -829,7 +806,7 @@ async fn run_auto_compact(
)
.await?;
}
Ok(true)
Ok(())
}
pub(super) fn collect_explicit_app_ids_from_skill_items(
+7 -1
View File
@@ -2,6 +2,7 @@ use std::sync::Arc;
use super::SessionTask;
use super::SessionTaskContext;
use crate::session::TurnInput;
use crate::session::turn_context::TurnContext;
use crate::state::TaskKind;
use codex_protocol::user_input::UserInput;
@@ -23,7 +24,7 @@ impl SessionTask for CompactTask {
self: Arc<Self>,
session: Arc<SessionTaskContext>,
ctx: Arc<TurnContext>,
input: Vec<UserInput>,
_input: Vec<TurnInput>,
_cancellation_token: CancellationToken,
) -> Option<String> {
let session = session.clone_session();
@@ -47,6 +48,11 @@ impl SessionTask for CompactTask {
/*inc*/ 1,
&[("type", "local")],
);
let input = vec![UserInput::Text {
text: ctx.compact_prompt().to_string(),
// Compaction prompt is synthesized; no UI element ranges to preserve.
text_elements: Vec::new(),
}];
crate::compact::run_compact_task(session.clone(), ctx, input).await
};
None
+9 -4
View File
@@ -214,7 +214,7 @@ pub(crate) trait SessionTask: Send + Sync + 'static {
self: Arc<Self>,
session: Arc<SessionTaskContext>,
ctx: Arc<TurnContext>,
input: Vec<UserInput>,
input: Vec<TurnInput>,
cancellation_token: CancellationToken,
) -> impl std::future::Future<Output = Option<String>> + Send;
@@ -245,7 +245,7 @@ pub(crate) trait AnySessionTask: Send + Sync + 'static {
self: Arc<Self>,
session: Arc<SessionTaskContext>,
ctx: Arc<TurnContext>,
input: Vec<UserInput>,
input: Vec<TurnInput>,
cancellation_token: CancellationToken,
) -> BoxFuture<'static, Option<String>>;
@@ -276,7 +276,7 @@ where
self: Arc<Self>,
session: Arc<SessionTaskContext>,
ctx: Arc<TurnContext>,
input: Vec<UserInput>,
input: Vec<TurnInput>,
cancellation_token: CancellationToken,
) -> BoxFuture<'static, Option<String>> {
Box::pin(SessionTask::run(
@@ -380,6 +380,11 @@ impl Session {
));
let ctx = Arc::clone(&turn_context);
let task_for_run = Arc::clone(&task);
let task_input = if input.is_empty() {
Vec::new()
} else {
vec![TurnInput::UserInput(input)]
};
let task_cancellation_token = cancellation_token.child_token();
// Task-owned turn spans keep a core-owned span open for the
// full task lifecycle after the submission dispatch span ends.
@@ -405,7 +410,7 @@ impl Session {
.run(
Arc::clone(&session_ctx),
ctx,
input,
task_input,
task_cancellation_token.child_token(),
)
.await;
+2 -2
View File
@@ -2,13 +2,13 @@ use std::sync::Arc;
use tokio_util::sync::CancellationToken;
use crate::session::TurnInput;
use crate::session::turn::run_turn;
use crate::session::turn_context::TurnContext;
use crate::session_startup_prewarm::SessionStartupPrewarmResolution;
use crate::state::TaskKind;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::TurnStartedEvent;
use codex_protocol::user_input::UserInput;
use tracing::Instrument;
use tracing::trace_span;
@@ -41,7 +41,7 @@ impl SessionTask for RegularTask {
self: Arc<Self>,
session: Arc<SessionTaskContext>,
ctx: Arc<TurnContext>,
input: Vec<UserInput>,
input: Vec<TurnInput>,
cancellation_token: CancellationToken,
) -> Option<String> {
let sess = session.clone_session();
+11 -2
View File
@@ -20,6 +20,7 @@ use crate::codex_delegate::run_codex_thread_one_shot;
use crate::config::Constrained;
use crate::review_format::format_review_findings_block;
use crate::review_format::render_review_output_text;
use crate::session::TurnInput;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use crate::state::TaskKind;
@@ -59,7 +60,7 @@ impl SessionTask for ReviewTask {
self: Arc<Self>,
session: Arc<SessionTaskContext>,
ctx: Arc<TurnContext>,
input: Vec<UserInput>,
input: Vec<TurnInput>,
cancellation_token: CancellationToken,
) -> Option<String> {
session.session.services.session_telemetry.counter(
@@ -68,11 +69,19 @@ impl SessionTask for ReviewTask {
&[],
);
let mut user_input = Vec::new();
for item in input {
match item {
TurnInput::UserInput(mut content) => user_input.append(&mut content),
TurnInput::ResponseInputItem(_) => {}
}
}
// Start sub-codex conversation and get the receiver for events.
let output = match start_review_conversation(
session.clone(),
ctx.clone(),
input,
user_input,
cancellation_token.clone(),
)
.await
+2 -2
View File
@@ -7,7 +7,6 @@ use codex_network_proxy::PROXY_ACTIVE_ENV_KEY;
use codex_network_proxy::PROXY_ENV_KEYS;
#[cfg(target_os = "macos")]
use codex_network_proxy::PROXY_GIT_SSH_COMMAND_ENV_KEY;
use codex_protocol::user_input::UserInput;
use tokio_util::sync::CancellationToken;
use tracing::error;
use uuid::Uuid;
@@ -17,6 +16,7 @@ use crate::exec::StdoutStream;
use crate::exec::execute_exec_request;
use crate::exec_env::create_env;
use crate::sandboxing::ExecRequest;
use crate::session::TurnInput;
use crate::session::turn_context::TurnContext;
use crate::state::TaskKind;
use crate::tools::format_exec_output_str;
@@ -77,7 +77,7 @@ impl SessionTask for UserShellCommandTask {
self: Arc<Self>,
session: Arc<SessionTaskContext>,
turn_context: Arc<TurnContext>,
_input: Vec<UserInput>,
_input: Vec<TurnInput>,
cancellation_token: CancellationToken,
) -> Option<String> {
execute_user_shell_command(
@@ -2,9 +2,12 @@ use serde::Deserialize;
use crate::function_tool::FunctionCallError;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::PostToolUsePayload;
use crate::tools::registry::PreToolUsePayload;
use crate::tools::registry::ToolExecutor;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
@@ -110,4 +113,22 @@ impl ToolExecutor<ToolInvocation> for CodeModeWaitHandler {
}
}
impl CoreToolRuntime for CodeModeWaitHandler {}
impl CoreToolRuntime for CodeModeWaitHandler {
fn pre_tool_use_payload(&self, _invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
// Code-mode `wait` is runtime control for an existing code cell, not a
// standalone user action. Tool calls made from code mode still flow
// through normal dispatch, but hooks should not block or rewrite the
// wait loop itself.
None
}
fn post_tool_use_payload(
&self,
_invocation: &ToolInvocation,
_result: &dyn ToolOutput,
) -> Option<PostToolUsePayload> {
// The wait result feeds code-mode control flow, so do not let
// PostToolUse replace it with model-facing hook feedback.
None
}
}
@@ -4,17 +4,12 @@ use codex_tools::ConversationHistory;
use codex_tools::ToolCall as ExtensionToolCall;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
use serde_json::Value;
use crate::function_tool::FunctionCallError;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::flat_tool_name;
use crate::tools::hook_names::HookToolName;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::PostToolUsePayload;
use crate::tools::registry::PreToolUsePayload;
use crate::tools::registry::ToolExecutor;
pub(crate) struct ExtensionToolAdapter(Arc<dyn codex_tools::ToolExecutor<ExtensionToolCall>>);
@@ -23,13 +18,6 @@ impl ExtensionToolAdapter {
pub(crate) fn new(executor: Arc<dyn codex_tools::ToolExecutor<ExtensionToolCall>>) -> Self {
Self(executor)
}
fn arguments_from_payload<'a>(&self, payload: &'a ToolPayload) -> Option<&'a str> {
let ToolPayload::Function { arguments } = payload else {
return None;
};
Some(arguments)
}
}
#[async_trait::async_trait]
@@ -60,30 +48,7 @@ impl ToolExecutor<ToolInvocation> for ExtensionToolAdapter {
impl CoreToolRuntime for ExtensionToolAdapter {
fn matches_kind(&self, payload: &ToolPayload) -> bool {
self.arguments_from_payload(payload).is_some()
}
fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
let arguments = self.arguments_from_payload(&invocation.payload)?;
Some(PreToolUsePayload {
tool_name: HookToolName::new(flat_tool_name(&self.tool_name()).into_owned()),
tool_input: extension_tool_hook_input(arguments),
})
}
fn post_tool_use_payload(
&self,
invocation: &ToolInvocation,
result: &dyn ToolOutput,
) -> Option<PostToolUsePayload> {
let arguments = self.arguments_from_payload(&invocation.payload)?;
Some(PostToolUsePayload {
tool_name: HookToolName::new(flat_tool_name(&self.tool_name()).into_owned()),
tool_use_id: invocation.call_id.clone(),
tool_input: extension_tool_hook_input(arguments),
tool_response: result
.post_tool_use_response(&invocation.call_id, &invocation.payload)?,
})
matches!(payload, ToolPayload::Function { .. })
}
}
@@ -100,14 +65,6 @@ async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall {
}
}
fn extension_tool_hook_input(arguments: &str) -> Value {
if arguments.trim().is_empty() {
return Value::Object(serde_json::Map::new());
}
serde_json::from_str(arguments).unwrap_or_else(|_| Value::String(arguments.to_string()))
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
+3 -70
View File
@@ -9,10 +9,7 @@ use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::flat_tool_name;
use crate::tools::hook_names::HookToolName;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::PostToolUsePayload;
use crate::tools::registry::PreToolUsePayload;
use crate::tools::registry::ToolExecutor;
use crate::tools::registry::ToolTelemetryTags;
use crate::tools::tool_search_entry::ToolSearchInfo;
@@ -23,8 +20,6 @@ use codex_tools::ToolName;
use codex_tools::ToolSearchSourceInfo;
use codex_tools::ToolSpec;
use codex_tools::mcp_tool_to_responses_api_tool;
use serde_json::Map;
use serde_json::Value;
pub struct McpHandler {
tool_info: ToolInfo,
@@ -143,58 +138,6 @@ impl CoreToolRuntime for McpHandler {
tags
})
}
fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
let ToolPayload::Function { arguments } = &invocation.payload else {
return None;
};
Some(PreToolUsePayload {
tool_name: HookToolName::new(self.tool_name().to_string()),
tool_input: mcp_hook_tool_input(arguments),
})
}
fn with_updated_hook_input(
&self,
mut invocation: ToolInvocation,
updated_input: Value,
) -> Result<ToolInvocation, FunctionCallError> {
invocation.payload = match invocation.payload {
ToolPayload::Function { .. } => ToolPayload::Function {
arguments: serde_json::to_string(&updated_input).map_err(|err| {
FunctionCallError::RespondToModel(format!(
"failed to serialize rewritten MCP arguments: {err}"
))
})?,
},
payload => {
return Err(FunctionCallError::RespondToModel(format!(
"tool {} does not support hook input rewriting for payload {payload:?}",
self.tool_name()
)));
}
};
Ok(invocation)
}
fn post_tool_use_payload(
&self,
invocation: &ToolInvocation,
result: &dyn crate::tools::context::ToolOutput,
) -> Option<PostToolUsePayload> {
let ToolPayload::Function { .. } = &invocation.payload else {
return None;
};
let tool_response =
result.post_tool_use_response(&invocation.call_id, &invocation.payload)?;
Some(PostToolUsePayload {
tool_name: HookToolName::new(self.tool_name().to_string()),
tool_use_id: invocation.call_id.clone(),
tool_input: result.post_tool_use_input(&invocation.payload)?,
tool_response,
})
}
}
fn create_tool_spec(tool_info: &ToolInfo) -> Result<ToolSpec, serde_json::Error> {
@@ -223,14 +166,6 @@ fn create_tool_spec(tool_info: &ToolInfo) -> Result<ToolSpec, serde_json::Error>
}))
}
fn mcp_hook_tool_input(raw_arguments: &str) -> Value {
if raw_arguments.trim().is_empty() {
return Value::Object(Map::new());
}
serde_json::from_str(raw_arguments).unwrap_or_else(|_| Value::String(raw_arguments.to_string()))
}
fn build_mcp_search_text(info: &ToolInfo) -> String {
let tool_name = info.canonical_tool_name();
let mut schema_properties = info
@@ -288,6 +223,9 @@ mod tests {
use super::*;
use crate::session::tests::make_session_and_context;
use crate::tools::context::ToolCallSource;
use crate::tools::hook_names::HookToolName;
use crate::tools::registry::PostToolUsePayload;
use crate::tools::registry::PreToolUsePayload;
use crate::turn_diff_tracker::TurnDiffTracker;
use pretty_assertions::assert_eq;
use serde_json::json;
@@ -447,11 +385,6 @@ mod tests {
);
}
#[test]
fn mcp_hook_tool_input_defaults_empty_args_to_object() {
assert_eq!(mcp_hook_tool_input(" "), json!({}));
}
#[test]
fn mcp_read_only_hint_supports_parallel_calls_without_server_opt_in() {
let mut read_only_info = tool_info("foo", "mcp__foo__", "read");
@@ -5,6 +5,7 @@ use crate::tools::context::boxed_tool_output;
use crate::tools::handlers::parse_arguments;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::PostToolUsePayload;
use crate::tools::registry::PreToolUsePayload;
use crate::tools::registry::ToolExecutor;
use crate::unified_exec::WriteStdinRequest;
use codex_protocol::protocol::EventMsg;
@@ -101,11 +102,20 @@ impl CoreToolRuntime for WriteStdinHandler {
matches!(payload, ToolPayload::Function { .. })
}
fn pre_tool_use_payload(&self, _invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
// `write_stdin` is transport for an existing exec session. Empty writes
// are background polls, and non-empty writes continue a command that
// already ran PreToolUse as Bash, so do not emit a second pre hook here.
None
}
fn post_tool_use_payload(
&self,
invocation: &ToolInvocation,
result: &dyn crate::tools::context::ToolOutput,
) -> Option<PostToolUsePayload> {
// A `write_stdin` poll can observe final completion for the original
// `exec_command`; emit that command's matching Bash PostToolUse.
post_unified_exec_tool_use_payload(invocation, result)
}
}
+12
View File
@@ -38,6 +38,18 @@ impl HookToolName {
}
}
/// Returns the hook identity for spawning sub-agents.
///
/// The serialized name remains `spawn_agent`, while `Agent` is accepted as
/// a matcher alias for compatibility with hook configurations that describe
/// sub-agent creation using Claude Code-style names.
pub(crate) fn spawn_agent() -> Self {
Self {
name: "spawn_agent".to_string(),
matcher_aliases: vec!["Agent".to_string()],
}
}
/// Returns the hook identity historically used for shell-like tools.
pub(crate) fn bash() -> Self {
Self::new("Bash")
+111 -15
View File
@@ -19,6 +19,7 @@ use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::flat_tool_name;
use crate::tools::handlers::multi_agents_spec::MULTI_AGENT_V1_NAMESPACE;
use crate::tools::hook_names::HookToolName;
use crate::tools::lifecycle::notify_tool_finish;
use crate::tools::lifecycle::notify_tool_start;
@@ -26,6 +27,7 @@ use crate::tools::tool_dispatch_trace::ToolDispatchTrace;
use crate::tools::tool_search_entry::ToolSearchInfo;
use crate::util::error_or_panic;
use codex_extension_api::ToolCallOutcome;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::protocol::EventMsg;
use codex_tools::ToolName;
@@ -64,14 +66,47 @@ pub(crate) trait CoreToolRuntime: ToolExecutor<ToolInvocation> {
fn post_tool_use_payload(
&self,
_invocation: &ToolInvocation,
_result: &dyn ToolOutput,
invocation: &ToolInvocation,
result: &dyn ToolOutput,
) -> Option<PostToolUsePayload> {
None
let ToolPayload::Function { arguments } = &invocation.payload else {
return None;
};
Some(PostToolUsePayload {
tool_name: function_hook_tool_name(invocation),
tool_use_id: result.post_tool_use_id(&invocation.call_id),
tool_input: result
.post_tool_use_input(&invocation.payload)
.unwrap_or_else(|| function_hook_tool_input(arguments)),
tool_response: result
.post_tool_use_response(&invocation.call_id, &invocation.payload)
.or_else(|| {
// Most function tools can expose their model-facing output
// as the hook response. Outputs with a more stable hook
// contract should override post_tool_use_response above.
let ResponseInputItem::FunctionCallOutput {
output: FunctionCallOutputPayload { body, .. },
..
} = result.to_response_item(&invocation.call_id, &invocation.payload)
else {
return None;
};
serde_json::to_value(body).ok()
})?,
})
}
fn pre_tool_use_payload(&self, _invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
None
fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
let ToolPayload::Function { arguments } = &invocation.payload else {
return None;
};
Some(PreToolUsePayload {
tool_name: function_hook_tool_name(invocation),
tool_input: function_hook_tool_input(arguments),
})
}
/// Rebuilds a tool invocation from hook-facing `tool_input`.
@@ -80,12 +115,25 @@ pub(crate) trait CoreToolRuntime: ToolExecutor<ToolInvocation> {
/// hook contract they expose from `pre_tool_use_payload`.
fn with_updated_hook_input(
&self,
_invocation: ToolInvocation,
_updated_input: Value,
invocation: ToolInvocation,
updated_input: Value,
) -> Result<ToolInvocation, FunctionCallError> {
Err(FunctionCallError::RespondToModel(
"tool does not support hook input rewriting".to_string(),
))
let ToolPayload::Function { .. } = &invocation.payload else {
return Err(FunctionCallError::RespondToModel(
"hook input rewrite received unsupported function tool payload".to_string(),
));
};
let arguments = serde_json::to_string(&updated_input).map_err(|err| {
FunctionCallError::RespondToModel(format!(
"failed to serialize rewritten {} arguments: {err}",
flat_tool_name(&invocation.tool_name)
))
})?;
Ok(ToolInvocation {
payload: ToolPayload::Function { arguments },
..invocation
})
}
/// Creates an optional consumer for streamed tool argument diffs.
@@ -133,6 +181,29 @@ impl AnyToolResult {
}
}
struct PostToolUseFeedbackOutput {
original: Box<dyn ToolOutput>,
model_visible: FunctionToolOutput,
}
impl ToolOutput for PostToolUseFeedbackOutput {
fn log_preview(&self) -> String {
self.original.log_preview()
}
fn success_for_logging(&self) -> bool {
self.original.success_for_logging()
}
fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
self.model_visible.to_response_item(call_id, payload)
}
fn code_mode_result(&self, payload: &ToolPayload) -> Value {
self.original.code_mode_result(payload)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PreToolUsePayload {
/// Hook-facing tool name model.
@@ -538,11 +609,15 @@ impl ToolRegistry {
};
if let Some(replacement_text) = replacement_text {
let mut guard = response_cell.lock().await;
if let Some(result) = guard.as_mut() {
result.result = Box::new(FunctionToolOutput::from_text(
replacement_text,
/*success*/ None,
));
if let Some(mut result) = guard.take() {
result.result = Box::new(PostToolUseFeedbackOutput {
original: result.result,
model_visible: FunctionToolOutput::from_text(
replacement_text,
/*success*/ None,
),
});
*guard = Some(result);
}
}
}
@@ -618,6 +693,27 @@ async fn handle_any_tool(
})
}
fn function_hook_tool_name(invocation: &ToolInvocation) -> HookToolName {
if invocation.tool_name.name == "spawn_agent"
&& matches!(
invocation.tool_name.namespace.as_deref(),
None | Some(MULTI_AGENT_V1_NAMESPACE)
)
{
return HookToolName::spawn_agent();
}
HookToolName::new(flat_tool_name(&invocation.tool_name).into_owned())
}
fn function_hook_tool_input(arguments: &str) -> Value {
if arguments.trim().is_empty() {
return Value::Object(serde_json::Map::new());
}
serde_json::from_str(arguments).unwrap_or_else(|_| Value::String(arguments.to_string()))
}
fn unsupported_tool_call_message(payload: &ToolPayload, tool_name: &ToolName) -> String {
match payload {
ToolPayload::Custom { .. } => format!("unsupported custom tool call: {tool_name}"),
+192
View File
@@ -172,6 +172,198 @@ fn handler_looks_up_namespaced_aliases_explicitly() {
);
}
#[tokio::test]
async fn function_tools_expose_default_hook_payloads_and_rewrites() -> anyhow::Result<()> {
let (session, turn) = crate::session::tests::make_session_and_context().await;
let tool_name = codex_tools::ToolName::namespaced("functions.", "echo");
let handler = TestHandler {
tool_name: tool_name.clone(),
};
let invocation = ToolInvocation {
payload: ToolPayload::Function {
arguments: serde_json::json!({ "message": "hello" }).to_string(),
},
..test_invocation(Arc::new(session), Arc::new(turn), "call-1", tool_name)
};
let output =
crate::tools::context::FunctionToolOutput::from_text("echoed".to_string(), Some(true));
assert_eq!(
handler.pre_tool_use_payload(&invocation),
Some(PreToolUsePayload {
tool_name: HookToolName::new("functions.echo"),
tool_input: serde_json::json!({ "message": "hello" }),
})
);
assert_eq!(
handler.post_tool_use_payload(&invocation, &output),
Some(PostToolUsePayload {
tool_name: HookToolName::new("functions.echo"),
tool_use_id: "call-1".to_string(),
tool_input: serde_json::json!({ "message": "hello" }),
tool_response: serde_json::json!("echoed"),
})
);
let invocation = handler
.with_updated_hook_input(invocation, serde_json::json!({ "message": "rewritten" }))?;
let ToolPayload::Function { arguments } = invocation.payload else {
panic!("generic rewritten function payload should remain function-shaped");
};
assert_eq!(
serde_json::from_str::<serde_json::Value>(&arguments)?,
serde_json::json!({ "message": "rewritten" })
);
Ok(())
}
#[tokio::test]
async fn function_hook_input_defaults_empty_arguments_to_object() {
let (session, turn) = crate::session::tests::make_session_and_context().await;
let tool_name = codex_tools::ToolName::plain("echo");
let handler = TestHandler {
tool_name: tool_name.clone(),
};
let invocation = ToolInvocation {
payload: ToolPayload::Function {
arguments: " ".to_string(),
},
..test_invocation(Arc::new(session), Arc::new(turn), "call-1", tool_name)
};
assert_eq!(
handler.pre_tool_use_payload(&invocation),
Some(PreToolUsePayload {
tool_name: HookToolName::new("echo"),
tool_input: serde_json::json!({}),
})
);
}
#[tokio::test]
async fn spawn_agent_function_tools_use_agent_matcher_alias() {
let (session, turn) = crate::session::tests::make_session_and_context().await;
let session = Arc::new(session);
let turn = Arc::new(turn);
let hook_payloads = [
codex_tools::ToolName::plain("spawn_agent"),
codex_tools::ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, "spawn_agent"),
]
.into_iter()
.map(|tool_name| {
let handler = TestHandler {
tool_name: tool_name.clone(),
};
let invocation = ToolInvocation {
payload: ToolPayload::Function {
arguments: serde_json::json!({ "message": "inspect this repo" }).to_string(),
},
..test_invocation(Arc::clone(&session), Arc::clone(&turn), "call-1", tool_name)
};
handler.pre_tool_use_payload(&invocation)
})
.collect::<Vec<_>>();
assert_eq!(
hook_payloads,
vec![
Some(PreToolUsePayload {
tool_name: HookToolName::spawn_agent(),
tool_input: serde_json::json!({ "message": "inspect this repo" }),
}),
Some(PreToolUsePayload {
tool_name: HookToolName::spawn_agent(),
tool_input: serde_json::json!({ "message": "inspect this repo" }),
}),
]
);
}
#[tokio::test]
async fn code_mode_wait_does_not_expose_default_hook_payloads() {
let (session, turn) = crate::session::tests::make_session_and_context().await;
let output = crate::tools::context::FunctionToolOutput::from_text("ok".to_string(), Some(true));
let wait = crate::tools::handlers::CodeModeWaitHandler;
let wait_invocation = test_invocation(
Arc::new(session),
Arc::new(turn),
"wait-call",
wait.tool_name(),
);
assert_eq!(wait.pre_tool_use_payload(&wait_invocation), None);
assert_eq!(wait.post_tool_use_payload(&wait_invocation, &output), None);
}
#[tokio::test]
async fn write_stdin_does_not_expose_default_pre_tool_use_payload() {
let (session, turn) = crate::session::tests::make_session_and_context().await;
let write_stdin = crate::tools::handlers::WriteStdinHandler;
let invocation = test_invocation(
Arc::new(session),
Arc::new(turn),
"write-stdin-call",
write_stdin.tool_name(),
);
assert_eq!(write_stdin.pre_tool_use_payload(&invocation), None);
}
#[test]
fn post_tool_use_feedback_output_keeps_code_mode_result_typed() {
let result = AnyToolResult {
call_id: "call-1".to_string(),
payload: ToolPayload::Function {
arguments: "{}".to_string(),
},
result: Box::new(PostToolUseFeedbackOutput {
original: Box::new(codex_tools::JsonToolOutput::new(
serde_json::json!({ "typed": true }),
)),
model_visible: crate::tools::context::FunctionToolOutput::from_text(
"hook feedback".to_string(),
/*success*/ None,
),
}),
post_tool_use_payload: None,
};
assert_eq!(
result.into_response(),
ResponseInputItem::FunctionCallOutput {
call_id: "call-1".to_string(),
output: codex_protocol::models::FunctionCallOutputPayload::from_text(
"hook feedback".to_string()
),
}
);
let result = AnyToolResult {
call_id: "call-1".to_string(),
payload: ToolPayload::Function {
arguments: "{}".to_string(),
},
result: Box::new(PostToolUseFeedbackOutput {
original: Box::new(codex_tools::JsonToolOutput::new(
serde_json::json!({ "typed": true }),
)),
model_visible: crate::tools::context::FunctionToolOutput::from_text(
"hook feedback".to_string(),
/*success*/ None,
),
}),
post_tool_use_payload: None,
};
assert_eq!(
result.code_mode_result(),
serde_json::json!({ "typed": true })
);
}
#[tokio::test]
async fn dispatch_notifies_tool_lifecycle_contributors() -> anyhow::Result<()> {
let (mut session, turn) = crate::session::tests::make_session_and_context().await;
+86 -94
View File
@@ -3463,42 +3463,35 @@ async fn pre_tool_use_blocks_apply_patch_with_write_alias() -> Result<()> {
}
#[tokio::test]
async fn pre_tool_use_does_not_fire_for_plan_tool() -> Result<()> {
async fn pre_tool_use_blocks_local_function_tool_before_execution() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "pretooluse-update-plan";
let args = serde_json::json!({
"plan": [{
"step": "watch the tide",
"status": "pending",
}]
});
let call_id = "pretooluse-local-function-tool";
let args = serde_json::json!({});
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
core_test_support::responses::ev_function_call(
call_id,
"update_plan",
&serde_json::to_string(&args)?,
),
ev_function_call(call_id, "test_sync_tool", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "plan updated"),
ev_assistant_message("msg-1", "local function hook blocked it"),
ev_completed("resp-2"),
]),
],
)
.await;
let reason = "blocked local function pre hook";
let mut builder = test_codex()
.with_model("test-gpt-5.1-codex")
.with_pre_build_hook(|home| {
if let Err(error) =
write_pre_tool_use_hook(home, /*matcher*/ None, "json_deny", "should not fire")
write_pre_tool_use_hook(home, Some("^test_sync_tool$"), "json_deny", reason)
{
panic!("failed to write pre tool use hook test fixture: {error}");
}
@@ -3506,7 +3499,8 @@ async fn pre_tool_use_does_not_fire_for_plan_tool() -> Result<()> {
.with_config(trust_discovered_hooks);
let test = builder.build(&server).await?;
test.submit_turn("update the plan").await?;
test.submit_turn("call the local function tool with the pre hook")
.await?;
let requests = responses.requests();
assert_eq!(requests.len(), 2);
@@ -3514,17 +3508,85 @@ async fn pre_tool_use_does_not_fire_for_plan_tool() -> Result<()> {
let output = output_item
.get("output")
.and_then(Value::as_str)
.expect("update plan output string");
.expect("blocked local function tool output string");
assert!(
!output.contains("should not fire"),
"non-shell tool output should not be blocked by PreToolUse",
output.contains(&format!(
"Tool call blocked by PreToolUse hook: {reason}. Tool: test_sync_tool"
)),
"blocked local function output should surface the hook reason and tool name",
);
let hook_log_path = test.codex_home_path().join("pre_tool_use_hook_log.jsonl");
assert!(
!hook_log_path.exists(),
"plan tool should not trigger pre tool use hooks",
);
let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?;
assert_eq!(hook_inputs.len(), 1);
assert_eq!(hook_inputs[0]["hook_event_name"], "PreToolUse");
assert_eq!(hook_inputs[0]["tool_name"], "test_sync_tool");
assert_eq!(hook_inputs[0]["tool_use_id"], call_id);
assert_eq!(hook_inputs[0]["tool_input"], args);
Ok(())
}
#[tokio::test]
async fn pre_tool_use_rewrites_local_function_tool_before_execution() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "pretooluse-local-function-tool-rewrite";
let original_args = serde_json::json!({
"barrier": {
"id": "pretooluse-local-function-invalid-barrier",
"participants": 0,
}
});
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(
call_id,
"test_sync_tool",
&serde_json::to_string(&original_args)?,
),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "local function hook rewrote it"),
ev_completed("resp-2"),
]),
],
)
.await;
let updated_input = serde_json::json!({});
let mut builder = test_codex()
.with_model("test-gpt-5.1-codex")
.with_pre_build_hook(move |home| {
if let Err(error) =
write_updating_pre_tool_use_hook(home, "^test_sync_tool$", &updated_input)
{
panic!("failed to write updating pre tool use hook test fixture: {error}");
}
})
.with_config(trust_discovered_hooks);
let test = builder.build(&server).await?;
test.submit_turn("call the local function tool with the pre hook rewrite")
.await?;
let requests = responses.requests();
assert_eq!(requests.len(), 2);
let output_item = requests[1].function_call_output(call_id);
let output = output_item
.get("output")
.and_then(Value::as_str)
.expect("rewritten local function tool output string");
assert_eq!(output, "ok");
let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?;
assert_eq!(hook_inputs.len(), 1);
assert_eq!(hook_inputs[0]["tool_input"], original_args);
Ok(())
}
@@ -4133,73 +4195,3 @@ async fn post_tool_use_records_apply_patch_context_with_edit_alias() -> Result<(
Ok(())
}
#[tokio::test]
async fn post_tool_use_does_not_fire_for_plan_tool() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "posttooluse-update-plan";
let args = serde_json::json!({
"plan": [{
"step": "watch the tide",
"status": "pending",
}]
});
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
core_test_support::responses::ev_function_call(
call_id,
"update_plan",
&serde_json::to_string(&args)?,
),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "plan updated"),
ev_completed("resp-2"),
]),
],
)
.await;
let mut builder = test_codex()
.with_pre_build_hook(|home| {
if let Err(error) = write_post_tool_use_hook(
home,
/*matcher*/ None,
"decision_block",
"should not fire",
) {
panic!("failed to write post tool use hook test fixture: {error}");
}
})
.with_config(trust_discovered_hooks);
let test = builder.build(&server).await?;
test.submit_turn("update the plan").await?;
let requests = responses.requests();
assert_eq!(requests.len(), 2);
let output_item = requests[1].function_call_output(call_id);
let output = output_item
.get("output")
.and_then(Value::as_str)
.expect("update plan output string");
assert!(
!output.contains("should not fire"),
"non-shell tool output should not be affected by PostToolUse",
);
let hook_log_path = test.codex_home_path().join("post_tool_use_hook_log.jsonl");
assert!(
!hook_log_path.exists(),
"plan tool should not trigger post tool use hooks",
);
Ok(())
}
+1 -1
View File
@@ -416,7 +416,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
codex_self_exe: arg0_paths.codex_self_exe.clone(),
codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe.clone(),
main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe.clone(),
zsh_path: None,
default_zsh_path: None,
base_instructions: None,
developer_instructions: None,
personality: None,
+35
View File
@@ -11,6 +11,7 @@ const PATH_DIRNAME: &str = "codex-path";
const RELEASES_DIRNAME: &str = "releases";
const RESOURCES_DIRNAME: &str = "codex-resources";
const STANDALONE_PACKAGES_DIRNAME: &str = "standalone";
const ZSH_DIRNAME: &str = "zsh";
static INSTALL_CONTEXT: OnceLock<InstallContext> = OnceLock::new();
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -166,6 +167,18 @@ impl InstallContext {
None
}
pub fn bundled_zsh_path(&self) -> Option<AbsolutePathBuf> {
if cfg!(windows) {
None
} else {
self.bundled_resource(zsh_resource_path())
}
}
pub fn bundled_zsh_bin_dir(&self) -> Option<AbsolutePathBuf> {
self.bundled_zsh_path()?.parent()
}
}
impl CodexPackageLayout {
@@ -260,6 +273,10 @@ fn default_rg_command() -> PathBuf {
}
}
fn zsh_resource_path() -> PathBuf {
PathBuf::from(ZSH_DIRNAME).join(BIN_DIRNAME).join("zsh")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -345,6 +362,11 @@ mod tests {
fs::write(&exe_path, "")?;
fs::write(resources_dir.join(TEST_RESOURCE_NAME), "")?;
fs::write(path_dir.join(default_rg_command()), "")?;
if !cfg!(windows) {
let zsh_path = resources_dir.join(zsh_resource_path());
fs::create_dir_all(zsh_path.parent().expect("zsh path should have parent"))?;
fs::write(&zsh_path, "")?;
}
let canonical_package_dir =
AbsolutePathBuf::from_absolute_path(package_dir.path().canonicalize()?)?;
let canonical_bin_dir = AbsolutePathBuf::from_absolute_path(bin_dir.canonicalize()?)?;
@@ -382,6 +404,19 @@ mod tests {
context.bundled_resource(TEST_RESOURCE_NAME),
Some(canonical_resources_dir.join(TEST_RESOURCE_NAME))
);
if cfg!(windows) {
assert_eq!(context.bundled_zsh_path(), None);
assert_eq!(context.bundled_zsh_bin_dir(), None);
} else {
assert_eq!(
context.bundled_zsh_path(),
Some(canonical_resources_dir.join(zsh_resource_path()))
);
assert_eq!(
context.bundled_zsh_bin_dir(),
Some(canonical_resources_dir.join(ZSH_DIRNAME).join(BIN_DIRNAME))
);
}
Ok(())
}
+34
View File
@@ -7,6 +7,7 @@ use crate::exec_output::ExecToolCallOutput;
use crate::network_policy::NetworkPolicyDecisionPayload;
use crate::protocol::CodexErrorInfo;
use crate::protocol::ErrorEvent;
use crate::protocol::RateLimitReachedType;
use crate::protocol::RateLimitSnapshot;
use crate::protocol::TruncationPolicy;
use chrono::DateTime;
@@ -451,6 +452,7 @@ pub struct UsageLimitReachedError {
pub resets_at: Option<DateTime<Utc>>,
pub rate_limits: Option<Box<RateLimitSnapshot>>,
pub promo_message: Option<String>,
pub rate_limit_reached_type: Option<RateLimitReachedType>,
}
impl std::fmt::Display for UsageLimitReachedError {
@@ -470,6 +472,38 @@ impl std::fmt::Display for UsageLimitReachedError {
);
}
if let Some(rate_limit_reached_type) = self.rate_limit_reached_type {
match rate_limit_reached_type {
RateLimitReachedType::WorkspaceOwnerCreditsDepleted => {
return write!(
f,
"Your workspace is out of credits. Add credits to continue."
);
}
RateLimitReachedType::WorkspaceMemberCreditsDepleted => {
return write!(
f,
"Your workspace is out of credits. Ask your workspace owner to refill in order to continue."
);
}
RateLimitReachedType::WorkspaceOwnerUsageLimitReached => {
return write!(
f,
"You hit your spend cap set in your workspace. Increase your spend cap to continue."
);
}
RateLimitReachedType::WorkspaceMemberUsageLimitReached => {
return write!(
f,
"You hit your spend cap set by the owner of your workspace. Ask an owner to increase your spend cap to continue."
);
}
RateLimitReachedType::RateLimitReached => {
// Generic limits intentionally use the existing promo or plan copy below.
}
}
}
if let Some(promo_message) = &self.promo_message {
return write!(
f,
+54
View File
@@ -56,6 +56,7 @@ fn usage_limit_reached_error_formats_plus_plan() {
resets_at: None,
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
assert_eq!(
err.to_string(),
@@ -63,6 +64,44 @@ fn usage_limit_reached_error_formats_plus_plan() {
);
}
#[test]
fn usage_limit_reached_error_formats_rate_limit_reached_types() {
let cases = [
(
RateLimitReachedType::RateLimitReached,
"You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again later.",
),
(
RateLimitReachedType::WorkspaceOwnerCreditsDepleted,
"Your workspace is out of credits. Add credits to continue.",
),
(
RateLimitReachedType::WorkspaceMemberCreditsDepleted,
"Your workspace is out of credits. Ask your workspace owner to refill in order to continue.",
),
(
RateLimitReachedType::WorkspaceOwnerUsageLimitReached,
"You hit your spend cap set in your workspace. Increase your spend cap to continue.",
),
(
RateLimitReachedType::WorkspaceMemberUsageLimitReached,
"You hit your spend cap set by the owner of your workspace. Ask an owner to increase your spend cap to continue.",
),
];
for (rate_limit_reached_type, expected) in cases {
let err = UsageLimitReachedError {
plan_type: Some(PlanType::Known(KnownPlan::Plus)),
resets_at: None,
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: Some(rate_limit_reached_type),
};
assert_eq!(err.to_string(), expected);
}
}
#[test]
fn server_overloaded_maps_to_protocol() {
let err = CodexErr::ServerOverloaded;
@@ -177,6 +216,7 @@ fn usage_limit_reached_error_formats_free_plan() {
resets_at: None,
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
assert_eq!(
err.to_string(),
@@ -191,6 +231,7 @@ fn usage_limit_reached_error_formats_go_plan() {
resets_at: None,
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
assert_eq!(
err.to_string(),
@@ -205,6 +246,7 @@ fn usage_limit_reached_error_formats_default_when_none() {
resets_at: None,
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
assert_eq!(
err.to_string(),
@@ -223,6 +265,7 @@ fn usage_limit_reached_error_formats_team_plan() {
resets_at: Some(resets_at),
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
let expected = format!(
"You've hit your usage limit. To get more access now, send a request to your admin or try again at {expected_time}."
@@ -238,6 +281,7 @@ fn usage_limit_reached_error_formats_business_plan_without_reset() {
resets_at: None,
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
assert_eq!(
err.to_string(),
@@ -252,6 +296,7 @@ fn usage_limit_reached_error_formats_self_serve_business_usage_based_plan() {
resets_at: None,
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
assert_eq!(
err.to_string(),
@@ -266,6 +311,7 @@ fn usage_limit_reached_error_formats_enterprise_cbp_usage_based_plan() {
resets_at: None,
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
assert_eq!(
err.to_string(),
@@ -280,6 +326,7 @@ fn usage_limit_reached_error_formats_default_for_other_plans() {
resets_at: None,
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
assert_eq!(
err.to_string(),
@@ -298,6 +345,7 @@ fn usage_limit_reached_error_formats_pro_plan_with_reset() {
resets_at: Some(resets_at),
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
let expected = format!(
"You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}."
@@ -324,6 +372,7 @@ fn usage_limit_reached_error_hides_upsell_for_non_codex_limit_name() {
"Visit https://chatgpt.com/codex/settings/usage to purchase more credits"
.to_string(),
),
rate_limit_reached_type: None,
};
let expected = format!(
"You've hit your usage limit for codex_other. Switch to another model now, or try again at {expected_time}."
@@ -343,6 +392,7 @@ fn usage_limit_reached_includes_minutes_when_available() {
resets_at: Some(resets_at),
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
let expected = format!("You've hit your usage limit. Try again at {expected_time}.");
assert_eq!(err.to_string(), expected);
@@ -482,6 +532,7 @@ fn usage_limit_reached_includes_hours_and_minutes() {
resets_at: Some(resets_at),
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
let expected = format!(
"You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}."
@@ -502,6 +553,7 @@ fn usage_limit_reached_includes_days_hours_minutes() {
resets_at: Some(resets_at),
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
let expected = format!("You've hit your usage limit. Try again at {expected_time}.");
assert_eq!(err.to_string(), expected);
@@ -519,6 +571,7 @@ fn usage_limit_reached_less_than_minute() {
resets_at: Some(resets_at),
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
let expected = format!("You've hit your usage limit. Try again at {expected_time}.");
assert_eq!(err.to_string(), expected);
@@ -538,6 +591,7 @@ fn usage_limit_reached_with_promo_message() {
promo_message: Some(
"To continue using Codex, start a free trial of <PLAN> today".to_string(),
),
rate_limit_reached_type: None,
};
let expected = format!(
"You've hit your usage limit. To continue using Codex, start a free trial of <PLAN> today, or try again at {expected_time}."
+15
View File
@@ -2000,6 +2000,21 @@ pub enum RateLimitReachedType {
WorkspaceMemberUsageLimitReached,
}
impl FromStr for RateLimitReachedType {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"rate_limit_reached" => Ok(Self::RateLimitReached),
"workspace_owner_credits_depleted" => Ok(Self::WorkspaceOwnerCreditsDepleted),
"workspace_member_credits_depleted" => Ok(Self::WorkspaceMemberCreditsDepleted),
"workspace_owner_usage_limit_reached" => Ok(Self::WorkspaceOwnerUsageLimitReached),
"workspace_member_usage_limit_reached" => Ok(Self::WorkspaceMemberUsageLimitReached),
other => Err(format!("unknown rate limit reached type: {other}")),
}
}
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)]
pub struct RateLimitWindow {
/// Percentage (0-100) of the window that has been consumed.
+1
View File
@@ -31,6 +31,7 @@ tracing = { workspace = true }
urlencoding = { workspace = true }
[dev-dependencies]
codex-utils-cargo-bin = { workspace = true }
pretty_assertions = { workspace = true }
[lib]
@@ -0,0 +1,85 @@
{
"source": "standard/google_calendar/tools",
"tools": [
{
"name": "google_calendar_create_event",
"description": "Create a calendar event.",
"input_schema": {
"type": "object",
"properties": {
"event": {
"$ref": "#/definitions/Event"
},
"notify_attendees": {
"type": "boolean",
"description": "Whether attendees should receive notifications."
}
},
"required": [
"event"
],
"definitions": {
"Event": {
"type": "object",
"description": "Calendar event payload.",
"properties": {
"title": {
"type": "string",
"description": "Event title."
},
"start": {
"anyOf": [
{
"$ref": "#/definitions/DateTime"
},
{
"type": "null"
}
]
}
}
},
"DateTime": {
"type": "object",
"description": "Calendar date-time.",
"properties": {
"dateTime": {
"type": "string",
"format": "date-time",
"description": "RFC3339 date-time."
},
"timeZone": {
"type": "string",
"enum": [
"UTC",
"America/Los_Angeles"
],
"description": "IANA time zone."
}
}
},
"UnusedCalendarResource": {
"type": "string",
"description": "Unreachable calendar resource."
}
}
},
"expected_preserved": [
{
"pointer": "/properties/event/$ref",
"value": "#/definitions/Event"
},
{
"pointer": "/definitions/DateTime/properties/timeZone/enum/1",
"value": "America/Los_Angeles"
}
],
"expected_pruned": [
"/definitions/UnusedCalendarResource"
],
"expected_dropped_fields": [
"/definitions/DateTime/properties/dateTime/format"
]
}
]
}
@@ -0,0 +1,65 @@
{
"source": "standard/google_drive/tools",
"tools": [
{
"name": "google_drive_copy_file",
"description": "Copy a Google Drive file.",
"input_schema": {
"type": "object",
"properties": {
"file": {
"description": "File selector.",
"oneOf": [
{
"type": "string",
"description": "A Drive file ID."
},
{
"type": "object",
"properties": {
"shared_drive_id": {
"type": "string",
"description": "Shared drive identifier."
}
}
}
]
},
"metadata": {
"description": "Optional copy metadata.",
"allOf": [
{
"type": "object",
"description": "Base metadata.",
"properties": {
"source": {
"type": "string",
"description": "Metadata source."
}
}
}
]
},
"title": {
"type": "string",
"description": "Copied file title."
}
},
"required": [
"file"
]
},
"expected_preserved": [
{
"pointer": "/properties/title/description",
"value": "Copied file title."
}
],
"expected_pruned": [],
"expected_dropped_fields": [
"/properties/file/oneOf",
"/properties/metadata/allOf"
]
}
]
}
@@ -0,0 +1,90 @@
{
"source": "golden/microsoft_outlook_email/tools",
"tools": [
{
"name": "microsoft_outlook_email_send",
"description": "Send an Outlook email.",
"input_schema": {
"type": "object",
"properties": {
"message_id": {
"$ref": "#/$defs/Message/properties/id"
},
"attachment": {
"type": "array",
"items": {
"$ref": "#/$defs/Attachment"
}
}
},
"$defs": {
"Message": {
"type": "object",
"description": "Outlook message.",
"properties": {
"id": {
"type": "string",
"description": "Message identifier."
},
"sender": {
"$ref": "#/$defs/EmailAddress"
},
"importance": {
"type": "string",
"enum": [
"low",
"normal",
"high"
],
"description": "Message importance."
}
}
},
"Attachment": {
"type": "object",
"description": "Email attachment.",
"properties": {
"name": {
"type": "string",
"description": "Attachment file name."
}
}
},
"EmailAddress": {
"type": "object",
"description": "Email address object.",
"properties": {
"address": {
"type": "string",
"description": "SMTP address."
}
}
},
"GiantUnusedPayload": {
"type": "object",
"description": "Representative unreachable Outlook payload.",
"properties": {
"opaque": {
"type": "string"
}
}
}
}
},
"expected_preserved": [
{
"pointer": "/properties/message_id/$ref",
"value": "#/$defs/Message/properties/id"
},
{
"pointer": "/$defs/Message/properties/importance/enum/2",
"value": "high"
}
],
"expected_pruned": [
"/$defs/GiantUnusedPayload"
],
"expected_dropped_fields": []
}
]
}
@@ -0,0 +1,72 @@
{
"source": "golden/notion/tools",
"tools": [
{
"name": "notion_fetch_page",
"description": "Fetch a Notion page.",
"input_schema": {
"type": "object",
"properties": {
"page": {
"$ref": "#/$defs/Page%20Ref"
},
"filter": {
"type": "object",
"additionalProperties": {
"$ref": "#/$defs/Filter"
}
},
"content_mode": {
"description": "How much content to return.",
"anyOf": [
{
"type": "string",
"enum": [
"summary",
"full"
]
},
{
"type": "null"
}
]
}
},
"$defs": {
"Page Ref": {
"type": "string",
"description": "Notion page ID or URL."
},
"Filter": {
"type": "object",
"description": "Filter object.",
"properties": {
"created_by": {
"type": "string",
"description": "Creator user ID."
}
}
},
"UnusedDatabase": {
"type": "object",
"description": "Unreachable Notion database schema."
}
}
},
"expected_preserved": [
{
"pointer": "/properties/page/$ref",
"value": "#/$defs/Page%20Ref"
},
{
"pointer": "/properties/content_mode/anyOf/0/enum/0",
"value": "summary"
}
],
"expected_pruned": [
"/$defs/UnusedDatabase"
],
"expected_dropped_fields": []
}
]
}
@@ -0,0 +1,75 @@
{
"source": "standard/slack/tools",
"tools": [
{
"name": "slack_schedule_message",
"description": "Schedule a Slack message.",
"input_schema": {
"type": "object",
"properties": {
"channel_id": {
"type": "string",
"description": "Slack channel ID."
},
"message": {
"type": "string",
"description": "Message text."
},
"post_at": {
"type": "integer",
"description": "Unix timestamp for delivery."
},
"thread_ts": {
"description": "Optional parent thread timestamp.",
"anyOf": [
{
"$ref": "#/$defs/SlackTs"
},
{
"type": "null"
}
]
}
},
"required": [
"channel_id",
"message",
"post_at"
],
"additionalProperties": false,
"$defs": {
"SlackTs": {
"type": "string",
"description": "Slack timestamp string.",
"pattern": "^[0-9]+[.][0-9]+$"
},
"UnusedPayload": {
"type": "object",
"description": "Large unreachable Slack payload.",
"properties": {
"debug": {
"type": "string"
}
}
}
}
},
"expected_preserved": [
{
"pointer": "/properties/thread_ts/anyOf/0/$ref",
"value": "#/$defs/SlackTs"
},
{
"pointer": "/$defs/SlackTs/description",
"value": "Slack timestamp string."
}
],
"expected_pruned": [
"/$defs/UnusedPayload"
],
"expected_dropped_fields": [
"/$defs/SlackTs/pattern"
]
}
]
}
@@ -0,0 +1,224 @@
use codex_tools::ToolName;
use codex_tools::mcp_tool_to_responses_api_tool;
use pretty_assertions::assert_eq;
use serde::Deserialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
use serde_json::json;
use std::fs;
use std::sync::Arc;
const FIXTURE_PATHS: [&str; 5] = [
"tests/fixtures/json_schema_policy/slack.json",
"tests/fixtures/json_schema_policy/google_calendar.json",
"tests/fixtures/json_schema_policy/google_drive.json",
"tests/fixtures/json_schema_policy/notion.json",
"tests/fixtures/json_schema_policy/microsoft_outlook_email.json",
];
const OVERSIZED_NOTION_CREATE_PAGE_SCHEMA_PATH: &str =
"tests/fixtures/json_schema_policy/oversized_notion_create_page_input_schema.json";
#[derive(Debug, Deserialize)]
struct FixtureFile {
source: String,
tools: Vec<FixtureTool>,
}
#[derive(Debug, Deserialize)]
struct FixtureTool {
name: String,
description: String,
input_schema: Value,
#[serde(default)]
expected_preserved: Vec<ExpectedValue>,
#[serde(default)]
expected_pruned: Vec<String>,
#[serde(default)]
expected_dropped_fields: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct ExpectedValue {
pointer: String,
value: Value,
}
#[test]
fn json_schema_policy_fixtures_convert_to_responses_tools() {
for fixture in FIXTURE_PATHS.into_iter().map(load_fixture::<FixtureFile>) {
for fixture_tool in &fixture.tools {
let responses_tool = convert_fixture_tool(&fixture, fixture_tool);
let parameters = serde_json::to_value(&responses_tool.parameters)
.expect("responses parameters should serialize");
let expected_fields = [
(
"preserve the tool name",
json!(fixture_tool.name),
json!(responses_tool.name),
),
(
"preserve the tool description",
json!(fixture_tool.description),
json!(responses_tool.description),
),
(
"remain a strict:false tool",
json!(false),
json!(responses_tool.strict),
),
(
"produce object-shaped parameters",
json!("object"),
parameters.get("type").cloned().unwrap_or(Value::Null),
),
];
for (message, expected, actual) in expected_fields {
assert_eq!(actual, expected, "{} should {message}", fixture_tool.name);
}
assert!(
parameters.get("properties").is_some_and(Value::is_object),
"{} should produce a parameters.properties object",
fixture_tool.name
);
for expected in &fixture_tool.expected_preserved {
assert_eq!(
parameters.pointer(&expected.pointer),
Some(&expected.value),
"{} should preserve {}",
fixture_tool.name,
expected.pointer
);
}
for pointer in &fixture_tool.expected_pruned {
assert!(
parameters.pointer(pointer).is_none(),
"{} should prune unreachable definition {pointer}",
fixture_tool.name
);
}
for pointer in &fixture_tool.expected_dropped_fields {
assert!(
fixture_tool.input_schema.pointer(pointer).is_some(),
"{} fixture should contain expected dropped field {pointer}",
fixture_tool.name
);
assert!(
parameters.pointer(pointer).is_none(),
"{} should drop field {pointer} after JsonSchema conversion",
fixture_tool.name
);
}
}
}
}
#[test]
fn json_schema_policy_oversized_golden_schema_triggers_compaction() {
let fixture: FixtureFile = load_fixture(OVERSIZED_NOTION_CREATE_PAGE_SCHEMA_PATH);
let fixture_tool = fixture
.tools
.first()
.expect("oversized fixture should contain a tool");
let input_bytes = compact_json_len(&fixture_tool.input_schema);
let responses_tool = convert_fixture_tool(&fixture, fixture_tool);
let parameters =
serde_json::to_value(&responses_tool.parameters).expect("responses parameters serialize");
let output_bytes = compact_json_len(&parameters);
assert!(
output_bytes < input_bytes,
"compaction should reduce schema size from {input_bytes} bytes"
);
let absent_pointers = [
("/description", "drop root description"),
("/properties/parent/description", "drop nested descriptions"),
(
"/$defs",
"drop root definitions after stripping descriptions is insufficient",
),
];
for (pointer, message) in absent_pointers {
assert!(
parameters.pointer(pointer).is_none(),
"oversized schema should {message}"
);
}
let expected_values = [
(
"/properties/parent",
json!({}),
"rewrite local refs before dropping root definitions",
),
(
"/properties/children/items",
json!({}),
"rewrite nested local refs before dropping root definitions",
),
(
"/properties/markdown/type",
json!("string"),
"retain top-level argument shape",
),
(
"/properties/properties/type",
json!("object"),
"retain object argument shape",
),
];
for (pointer, expected, message) in expected_values {
assert_eq!(
parameters.pointer(pointer),
Some(&expected),
"oversized schema should {message}"
);
}
}
fn load_fixture<T: DeserializeOwned>(path: &str) -> T {
let path = codex_utils_cargo_bin::find_resource!(path)
.unwrap_or_else(|err| panic!("resolve fixture {path}: {err}"));
let fixture = fs::read_to_string(&path)
.unwrap_or_else(|err| panic!("read fixture {}: {err}", path.display()));
serde_json::from_str(&fixture)
.unwrap_or_else(|err| panic!("parse fixture {}: {err}", path.display()))
}
fn convert_fixture_tool(
fixture: &FixtureFile,
fixture_tool: &FixtureTool,
) -> codex_tools::ResponsesApiTool {
let name = &fixture_tool.name;
let input_schema = fixture_tool
.input_schema
.as_object()
.unwrap_or_else(|| panic!("{name} input_schema should be an object"))
.clone();
let tool = rmcp::model::Tool {
name: name.to_string().into(),
title: None,
description: Some(fixture_tool.description.clone().into()),
input_schema: Arc::new(input_schema),
output_schema: None,
annotations: None,
execution: None,
icons: None,
meta: None,
};
mcp_tool_to_responses_api_tool(&ToolName::namespaced(&fixture.source, name), &tool)
.unwrap_or_else(|err| panic!("convert {name} from {}: {err}", fixture.source))
}
fn compact_json_len(value: &Value) -> usize {
serde_json::to_vec(value)
.unwrap_or_else(|err| panic!("serialize compact JSON: {err}"))
.len()
}
+5
View File
@@ -16,7 +16,12 @@ thiserror = { workspace = true }
tokio = { workspace = true, features = ["fs", "rt", "rt-multi-thread", "macros"] }
[dev-dependencies]
divan = { workspace = true }
image = { workspace = true, features = ["jpeg", "png", "gif", "webp"] }
[lib]
doctest = false
[[bench]]
name = "prompt_images"
harness = false
@@ -0,0 +1,178 @@
use std::io::Cursor;
use std::path::Path;
use codex_utils_image::PromptImageMode;
use codex_utils_image::load_for_prompt_bytes;
use divan::Bencher;
use image::DynamicImage;
use image::ImageFormat;
use image::Rgb;
use image::RgbImage;
use image::Rgba;
use image::RgbaImage;
const CACHE_MISS_VARIANT_COUNT: usize = 48;
const SMALL_SCREENSHOT: ImageSize = ImageSize {
width: 1_536,
height: 864,
};
const LARGE_SCREENSHOT: ImageSize = ImageSize {
width: 2_560,
height: 1_440,
};
const LARGE_PHOTO: ImageSize = ImageSize {
width: 3_264,
height: 2_448,
};
#[derive(Clone, Copy)]
struct ImageSize {
width: u32,
height: u32,
}
fn main() {
divan::main();
}
#[divan::bench]
fn small_png_screenshot_fresh_attachment(bencher: Bencher) {
bench_fresh_attachment(
bencher,
"small-screenshot.png",
cache_miss_variants(screenshot_png(SMALL_SCREENSHOT)),
);
}
#[divan::bench]
fn large_png_screenshot_fresh_attachment(bencher: Bencher) {
bench_fresh_attachment(
bencher,
"large-screenshot.png",
cache_miss_variants(screenshot_png(LARGE_SCREENSHOT)),
);
}
#[divan::bench]
fn large_jpeg_photo_fresh_attachment(bencher: Bencher) {
bench_fresh_attachment(
bencher,
"large-photo.jpg",
cache_miss_variants(photo_jpeg(LARGE_PHOTO)),
);
}
#[divan::bench]
fn small_png_screenshot_repeated_attachment(bencher: Bencher) {
bench_repeated_attachment(
bencher,
"small-screenshot.png",
screenshot_png(SMALL_SCREENSHOT),
);
}
fn bench_fresh_attachment(bencher: Bencher, path: &'static str, images: Vec<Vec<u8>>) {
let mut image_index = 0;
bencher
// Divan excludes `with_inputs` from the measured benchmark timing.
.with_inputs(move || {
let image = images[image_index].clone();
image_index = (image_index + 1) % images.len();
image
})
.bench_local_values(move |image| prepare_prompt_data_url(path, image));
}
fn bench_repeated_attachment(bencher: Bencher, path: &'static str, image: Vec<u8>) {
let _ = prepare_prompt_data_url(path, image.clone());
bencher
// Divan excludes the per-iteration input clone from measured timing.
.with_inputs(move || image.clone())
.bench_local_values(move |image| prepare_prompt_data_url(path, image));
}
fn prepare_prompt_data_url(path: &str, image: Vec<u8>) -> String {
#[allow(clippy::expect_used)]
load_for_prompt_bytes(Path::new(path), image, PromptImageMode::ResizeToFit)
.expect("benchmark fixture should load")
.into_data_url()
}
fn cache_miss_variants(image: Vec<u8>) -> Vec<Vec<u8>> {
// The loader caches by content digest. Suffixes keep this workload on the miss path.
(0..CACHE_MISS_VARIANT_COUNT)
.map(|variant| {
let mut image = image.clone();
image.extend_from_slice(&variant.to_le_bytes());
image
})
.collect()
}
/// Encodes a synthetic UI screenshot fixture for prompt image benchmarks.
fn screenshot_png(size: ImageSize) -> Vec<u8> {
let image = RgbaImage::from_fn(size.width, size.height, |x, y| {
let toolbar = y < 52;
let sidebar = x < 240;
let panel_border = x % 320 < 2 || y % 216 < 2;
let text_row = x > 270 && y > 88 && x % 19 < 13 && y % 31 < 3;
if toolbar {
Rgba([33, 40, 52, 255])
} else if sidebar {
let selection = y / 68 % 5 == 2;
if selection {
Rgba([65, 106, 171, 255])
} else {
Rgba([44, 54, 67, 255])
}
} else if panel_border {
Rgba([198, 205, 216, 255])
} else if text_row {
Rgba([72, 82, 96, 255])
} else {
let panel = ((x / 320) + (y / 216) * 3) % 4;
match panel {
0 => Rgba([246, 248, 252, 255]),
1 => Rgba([234, 241, 250, 255]),
2 => Rgba([240, 247, 236, 255]),
_ => Rgba([250, 240, 235, 255]),
}
}
});
encode_fixture(DynamicImage::ImageRgba8(image), ImageFormat::Png)
}
/// Encodes a synthetic textured photo fixture for prompt image benchmarks.
fn photo_jpeg(size: ImageSize) -> Vec<u8> {
let image = RgbImage::from_fn(size.width, size.height, |x, y| {
let x_gradient = x * 255 / size.width;
let y_gradient = y * 255 / size.height;
let texture = ((x.wrapping_mul(17) ^ y.wrapping_mul(31) ^ (x / 7) ^ (y / 11)) & 0xff) as u8;
Rgb([
blend_channel(x_gradient, texture, 3),
blend_channel((x_gradient + y_gradient) / 2, texture, 5),
blend_channel(255 - y_gradient, texture, 4),
])
});
encode_fixture(DynamicImage::ImageRgb8(image), ImageFormat::Jpeg)
}
fn blend_channel(gradient: u32, texture: u8, divisor: u32) -> u8 {
((gradient + u32::from(texture) / divisor) % 256) as u8
}
fn encode_fixture(image: DynamicImage, format: ImageFormat) -> Vec<u8> {
let mut encoded = Cursor::new(Vec::new());
#[allow(clippy::expect_used)]
image
.write_to(&mut encoded, format)
.expect("benchmark fixture should encode");
encoded.into_inner()
}
+9
View File
@@ -53,6 +53,15 @@ install:
# there should be no need to add `--all-features`.
test *args:
RUST_MIN_STACK={{ rust_min_stack }} cargo nextest run --no-fail-fast "$@"
just bench-smoke
# Run explicit workspace benchmark targets.
bench *args:
cargo bench --workspace --bench '*' "$@"
# Run benchmark targets once to ensure they start successfully.
bench-smoke:
just bench -- --test
# Build and run Codex from source using Bazel.
# Note we have to use the combination of `[no-cd]` and `--run_under="cd $PWD &&"`
+7
View File
@@ -13,6 +13,7 @@ The builder creates a canonical Codex package directory:
│ └── <entrypoint>[.exe]
├── codex-resources
│ ├── bwrap # Linux only
│ ├── zsh/bin/zsh # supported Unix targets only
│ ├── codex-command-runner.exe # Windows only
│ └── codex-windows-sandbox-setup.exe # Windows only
└── codex-path
@@ -67,3 +68,9 @@ DotSlash manifest at `scripts/codex_package/rg`. Downloaded archives are cached
under `$TMPDIR/codex-package/<target>-rg` and are reused only after the recorded
size and SHA-256 digest have been verified. Pass `--rg-bin` to use a local
ripgrep executable instead.
The patched zsh fork used by `shell_zsh_fork` is fetched from the DotSlash
manifest at `scripts/codex_package/codex-zsh` when the selected target has a
matching prebuilt artifact. Downloaded archives are cached under
`$TMPDIR/codex-package/<target>-zsh` and installed at
`codex-resources/zsh/bin/zsh`.
+3 -1
View File
@@ -15,6 +15,7 @@ from .targets import TARGET_SPECS
from .targets import PackageInputs
from .targets import default_target
from .targets import resolve_input_path
from .zsh import resolve_zsh_bin
from .version import read_workspace_version
@@ -161,13 +162,14 @@ def main() -> int:
inputs = PackageInputs(
entrypoint_bin=source_outputs.entrypoint_bin,
rg_bin=resolve_rg_bin(spec, args.rg_bin),
zsh_bin=resolve_zsh_bin(spec),
bwrap_bin=source_outputs.bwrap_bin,
codex_command_runner_bin=source_outputs.codex_command_runner_bin,
codex_windows_sandbox_setup_bin=source_outputs.codex_windows_sandbox_setup_bin,
)
prepare_package_dir(package_dir, force=args.force)
build_package_dir(package_dir, version, variant, spec, inputs)
validate_package_dir(package_dir, variant, spec)
validate_package_dir(package_dir, variant, spec, include_zsh=inputs.zsh_bin is not None)
for archive_output in args.archive_output:
archive_path = archive_output.resolve()
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env dotslash
{
"name": "codex-zsh",
"platforms": {
"macos-aarch64": {
"size": 358776,
"hash": "sha256",
"digest": "c6dbb063a0135b947ab1cacc655b2b750874699472f412ec7daba97543a90c3c",
"format": "tar.gz",
"path": "codex-zsh/bin/zsh",
"providers": [
{
"url": "https://github.com/openai/codex/releases/download/rust-v0.132.0/codex-zsh-aarch64-apple-darwin.tar.gz"
}
]
},
"linux-x86_64": {
"size": 433413,
"hash": "sha256",
"digest": "5f42d9fc8e9c8c399a727512002906006ae9de966ea7b3d87ca36b47efc59938",
"format": "tar.gz",
"path": "codex-zsh/bin/zsh",
"providers": [
{
"url": "https://github.com/openai/codex/releases/download/rust-v0.132.0/codex-zsh-x86_64-unknown-linux-musl.tar.gz"
}
]
},
"linux-aarch64": {
"size": 411653,
"hash": "sha256",
"digest": "6c6e32c297425db02b4dbffb10925895875d14647fc3eb2f18767be97dc6a945",
"format": "tar.gz",
"path": "codex-zsh/bin/zsh",
"providers": [
{
"url": "https://github.com/openai/codex/releases/download/rust-v0.132.0/codex-zsh-aarch64-unknown-linux-musl.tar.gz"
}
]
}
}
}
+16 -1
View File
@@ -8,6 +8,7 @@ from pathlib import Path
from .targets import PackageInputs
from .targets import PackageVariant
from .targets import TargetSpec
from .zsh import ZSH_RESOURCE_PATH
LAYOUT_VERSION = 1
@@ -50,6 +51,13 @@ def build_package_dir(
)
copy_executable(inputs.rg_bin, path_dir / spec.rg_name, is_windows=spec.is_windows)
if inputs.zsh_bin is not None:
copy_executable(
inputs.zsh_bin,
resources_dir / ZSH_RESOURCE_PATH,
is_windows=False,
)
if inputs.bwrap_bin is not None:
copy_executable(inputs.bwrap_bin, resources_dir / "bwrap", is_windows=False)
@@ -83,6 +91,8 @@ def validate_package_dir(
package_dir: Path,
variant: PackageVariant,
spec: TargetSpec,
*,
include_zsh: bool,
) -> None:
required_dirs = [
Path("bin"),
@@ -122,6 +132,11 @@ def validate_package_dir(
]
executable_files = list(required_files)
if include_zsh:
zsh_path = Path("codex-resources") / ZSH_RESOURCE_PATH
required_files.append(zsh_path)
executable_files.append(zsh_path)
if spec.is_linux:
required_files.append(Path("codex-resources") / "bwrap")
executable_files.append(Path("codex-resources") / "bwrap")
@@ -148,7 +163,7 @@ def validate_package_dir(
def copy_executable(src: Path, dest: Path, *, is_windows: bool) -> None:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest)
shutil.copyfile(src, dest)
if not is_windows:
mode = dest.stat().st_mode
dest.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
+1
View File
@@ -40,6 +40,7 @@ class PackageVariant:
class PackageInputs:
entrypoint_bin: Path
rg_bin: Path
zsh_bin: Path | None
bwrap_bin: Path | None
codex_command_runner_bin: Path | None
codex_windows_sandbox_setup_bin: Path | None
+22
View File
@@ -0,0 +1,22 @@
"""Fetch the patched zsh fork used by shell_zsh_fork."""
from pathlib import Path
from .dotslash import fetch_dotslash_executable
from .targets import REPO_ROOT
from .targets import TargetSpec
ZSH_MANIFEST = REPO_ROOT / "scripts" / "codex_package" / "codex-zsh"
ZSH_RESOURCE_PATH = Path("zsh") / "bin" / "zsh"
def resolve_zsh_bin(spec: TargetSpec) -> Path | None:
return fetch_dotslash_executable(
spec,
manifest_path=ZSH_MANIFEST,
artifact_label="codex-zsh",
cache_key=f"{spec.target}-zsh",
dest_name="zsh",
missing_ok=True,
)