Compare commits

..

10 Commits

Author SHA1 Message Date
David Wiesen
2a3b2d1b65 fix(windows-sandbox): disable private desktop by default in headless sessions 2026-04-15 04:11:48 -07:00
Michael Bolin
95845cf6ce fix: disable plugins in SDK integration tests (#16036)
## Why

The TypeScript SDK tests create a fresh `CODEX_HOME` for each Jest case
and delete it during teardown. That cleanup has been flaking because the
real `codex` binary can still be doing background curated-plugin startup
sync under `.tmp/plugins-clone-*`, which races the test harness's
recursive delete and leaves `ENOTEMPTY` failures behind.

This path is unrelated to what the SDK tests are exercising, so letting
plugin startup run during these tests only adds nondeterministic
filesystem activity. This showed up recently in the `sdk` CI lane for
[#16031](https://github.com/openai/codex/pull/16031).

## What Changed

- updated `sdk/typescript/tests/testCodex.ts` to merge test config
through a single helper
- disabled `features.plugins` unconditionally for SDK integration tests
so the CLI does not start curated-plugin sync in the temporary
`CODEX_HOME`
- preserved other explicit feature overrides from individual tests while
forcing `plugins` back to `false`
- kept the existing mock-provider override behavior intact for
SSE-backed tests

## Verification

- `pnpm test --runInBand`
- `pnpm lint`
2026-03-27 13:04:34 -07:00
Michael Bolin
15fbf9d4f5 fix: fix Windows CI regression introduced in #15999 (#16027)
#15999 introduced a Windows-only `\r\n` mismatch in review-exit template
handling. This PR normalizes those template newlines and separates that
fix from [#16014](https://github.com/openai/codex/pull/16014) so it can
be reviewed independently.
2026-03-27 12:06:07 -07:00
Michael Bolin
caee620a53 codex-tools: introduce named tool definitions (#15953)
## Why

This continues the `codex-tools` migration by moving one more piece of
generic tool-definition bookkeeping out of `codex-core`.

The earlier extraction steps moved shared schema parsing into
`codex-tools`, but `core/src/tools/spec.rs` still had to supply tool
names separately and perform ad hoc rewrites for deferred MCP aliases.
That meant the crate boundary was still awkward: the parsed shape coming
back from `codex-tools` was missing part of the definition that
`codex-core` ultimately needs to assemble a `ResponsesApiTool`.

This change introduces a named `ToolDefinition` in `codex-tools` so both
MCP tools and dynamic tools cross the crate boundary in the same
reusable model. `codex-core` still owns the final `ResponsesApiTool`
assembly, but less of the generic tool-definition shaping logic stays
behind in `core`.

## What changed

- replaced `ParsedToolDefinition` with a named `ToolDefinition` in
`codex-rs/tools/src/tool_definition.rs`
- added `codex-rs/tools/src/tool_definition_tests.rs` for `renamed()`
and `into_deferred()`
- updated `parse_dynamic_tool()` and `parse_mcp_tool()` to return
`ToolDefinition`
- simplified `codex-rs/core/src/tools/spec.rs` so it adapts
`ToolDefinition` into `ResponsesApiTool` instead of rewriting names and
deferred fields inline
- updated parser tests and `codex-rs/tools/README.md` to reflect the
named tool-definition model

## Test plan

- `cargo test -p codex-tools`
- `cargo test -p codex-core --lib tools::spec::`
2026-03-27 12:02:55 -07:00
Michael Bolin
2616c7cf12 ci: add Bazel clippy workflow for codex-rs (#15955)
## Why
`bazel.yml` already builds and tests the Bazel graph, but `rust-ci.yml`
still runs `cargo clippy` separately. This PR starts the transition to a
Bazel-backed lint lane for `codex-rs` so we can eventually replace the
duplicate Rust build, test, and lint work with Bazel while explicitly
keeping the V8 Bazel path out of scope for now.

To make that lane practical, the workflow also needs to look like the
Bazel job we already trust. That means sharing the common Bazel setup
and invocation logic instead of hand-copying it, and covering the arm64
macOS path in addition to Linux.

Landing the workflow green also required fixing the first lint findings
that Bazel surfaced and adding the matching local entrypoint.

## What changed
- add a reusable `build:clippy` config to `.bazelrc` and export
`codex-rs/clippy.toml` from `codex-rs/BUILD.bazel` so Bazel can run the
repository's existing Clippy policy
- add `just bazel-clippy` so the local developer entrypoint matches the
new CI lane
- extend `.github/workflows/bazel.yml` with a dedicated Bazel clippy job
for `codex-rs`, scoped to `//codex-rs/... -//codex-rs/v8-poc:all`
- run that clippy job on Linux x64 and arm64 macOS
- factor the shared Bazel workflow setup into
`.github/actions/setup-bazel-ci/action.yml` and the shared Bazel
invocation logic into `.github/scripts/run-bazel-ci.sh` so the clippy
and build/test jobs stay aligned
- fix the first Bazel-clippy findings needed to keep the lane green,
including the cross-target `cmsghdr::cmsg_len` normalization in
`codex-rs/shell-escalation/src/unix/socket.rs` and the no-`voice-input`
dead-code warnings in `codex-rs/tui` and `codex-rs/tui_app_server`

## Verification
- `just bazel-clippy`
- `RUNNER_OS=macOS ./.github/scripts/run-bazel-ci.sh -- build
--config=clippy --build_metadata=COMMIT_SHA=local-check
--build_metadata=TAG_job=clippy -- //codex-rs/...
-//codex-rs/v8-poc:all`
- `bazel build --config=clippy
//codex-rs/shell-escalation:shell-escalation`
- `CARGO_TARGET_DIR=/tmp/codex4-shell-escalation-test cargo test -p
codex-shell-escalation`
- `ruby -e 'require "yaml";
YAML.load_file(".github/workflows/bazel.yml");
YAML.load_file(".github/actions/setup-bazel-ci/action.yml")'`

## Notes
- `CARGO_TARGET_DIR=/tmp/codex4-tui-app-server-test cargo test -p
codex-tui-app-server` still hits existing guardian-approvals test and
snapshot failures unrelated to this PR's Bazel-clippy changes.

Related: #15954
2026-03-27 12:02:41 -07:00
Michael Bolin
617475e54b codex-tools: extract dynamic tool adapters (#15944)
## Why

`codex-tools` already owned the shared JSON schema parser and the MCP
tool schema adapter, but `core/src/tools/spec.rs` still parsed dynamic
tools directly.

That left the tool-schema boundary split in two different ways:

- MCP tools flowed through `codex-tools`, while dynamic tools were still
parsed in `codex-core`
- the extracted dynamic-tool path initially introduced a
dynamic-specific parsed shape even though `codex-tools` already had very
similar MCP adapter output

This change finishes that extraction boundary in one step. `codex-core`
still owns `ResponsesApiTool` assembly, but both MCP tools and dynamic
tools now enter that layer through `codex-tools` using the same parsed
tool-definition shape.

## What changed

- added `tools/src/dynamic_tool.rs` and sibling
`tools/src/dynamic_tool_tests.rs`
- introduced `parse_dynamic_tool()` in `codex-tools` and switched
`core/src/tools/spec.rs` to use it for dynamic tools
- added `tools/src/parsed_tool_definition.rs` so both MCP and dynamic
adapters return the same `ParsedToolDefinition`
- updated `core/src/tools/spec.rs` to build `ResponsesApiTool` through a
shared local adapter helper instead of separate MCP and dynamic assembly
paths
- expanded `core/src/tools/spec_tests.rs` so the dynamic-tool adapter
test asserts the full converted `ResponsesApiTool`, including
`defer_loading`
- updated `codex-rs/tools/README.md` to reflect the shared parsed
tool-definition boundary

## Test plan

- `cargo test -p codex-tools`
- `cargo test -p codex-core --lib tools::spec::`

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/15944).
* #15953
* __->__ #15944
2026-03-27 09:12:36 -07:00
viyatb-oai
ec089fd22a fix(sandbox): fix bwrap lookup for multi-entry PATH (#15973)
## Summary
- split the joined `PATH` before running system `bwrap` lookup
- keep the existing workspace-local `bwrap` skip behavior intact
- add regression tests that exercise real multi-entry search paths

## Why
The PATH-based lookup added in #15791 still wrapped the raw `PATH`
environment value as a single `PathBuf` before passing it through
`join_paths()`. On Unix, a normal multi-entry `PATH` contains `:`, so
that wrapper path is invalid as one path element and the lookup returns
`None`.

That made Codex behave as if no system `bwrap` was installed even when
`bwrap` was available on `PATH`, which is what users in #15340 were
still hitting on `0.117.0-alpha.25`.

## Impact
System `bwrap` discovery now works with normal multi-entry `PATH` values
instead of silently falling back to the vendored binary.

Fixes #15340.

## Validation
- `just fmt`
- `cargo test -p codex-sandboxing`
- `cargo test -p codex-linux-sandbox`
- `just fix -p codex-sandboxing`
- `just argument-comment-lint`
2026-03-27 08:41:06 -07:00
jif-oai
426f28ca99 feat: spawn v2 as inter agent communication (#15985)
Co-authored-by: Codex <noreply@openai.com>
2026-03-27 15:45:19 +01:00
jif-oai
2b71717ccf Use codex-utils-template for review exit XML (#15999) 2026-03-27 15:30:28 +01:00
jif-oai
f044ca64df Use codex-utils-template for search tool descriptions (#15996) 2026-03-27 15:08:24 +01:00
43 changed files with 991 additions and 630 deletions

View File

@@ -75,6 +75,11 @@ common:ci --disk_cache=
common:ci-bazel --config=ci
common:ci-bazel --build_metadata=TAG_workflow=bazel
# Shared config for Bazel-backed Rust linting.
build:clippy --aspects=@rules_rust//rust:defs.bzl%rust_clippy_aspect
build:clippy --output_groups=+clippy_checks
build:clippy --@rules_rust//rust/settings:clippy.toml=//codex-rs:clippy.toml
# Rearrange caches on Windows so they're on the same volume as the checkout.
common:ci-windows --config=ci-bazel
common:ci-windows --build_metadata=TAG_os=windows

View File

@@ -0,0 +1,61 @@
name: setup-bazel-ci
description: Prepare a Bazel CI runner with shared caches and optional test prerequisites.
inputs:
target:
description: Target triple used for cache namespacing.
required: true
install-test-prereqs:
description: Install Node.js and DotSlash for Bazel-backed test jobs.
required: false
default: "false"
outputs:
cache-hit:
description: Whether the Bazel repository cache key was restored exactly.
value: ${{ steps.cache_bazel_repository_restore.outputs.cache-hit }}
runs:
using: composite
steps:
- name: Set up Node.js for js_repl tests
if: inputs.install-test-prereqs == 'true'
uses: actions/setup-node@v6
with:
node-version-file: codex-rs/node-version.txt
# Some integration tests rely on DotSlash being installed.
# See https://github.com/openai/codex/pull/7617.
- name: Install DotSlash
if: inputs.install-test-prereqs == 'true'
uses: facebook/install-dotslash@v2
- name: Make DotSlash available in PATH (Unix)
if: inputs.install-test-prereqs == 'true' && runner.os != 'Windows'
shell: bash
run: cp "$(which dotslash)" /usr/local/bin
- name: Make DotSlash available in PATH (Windows)
if: inputs.install-test-prereqs == 'true' && runner.os == 'Windows'
shell: pwsh
run: Copy-Item (Get-Command dotslash).Source -Destination "$env:LOCALAPPDATA\Microsoft\WindowsApps\dotslash.exe"
- name: Set up Bazel
uses: bazelbuild/setup-bazelisk@v3
# Restore bazel repository cache so we don't have to redownload all the external dependencies
# on every CI run.
- name: Restore bazel repository cache
id: cache_bazel_repository_restore
uses: actions/cache/restore@v5
with:
path: |
~/.cache/bazel-repo-cache
key: bazel-cache-${{ inputs.target }}-${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }}
restore-keys: |
bazel-cache-${{ inputs.target }}
- name: Configure Bazel output root (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
# Use a very short path to reduce argv/path length issues.
"BAZEL_OUTPUT_USER_ROOT=C:\" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append

178
.github/scripts/run-bazel-ci.sh vendored Executable file
View File

@@ -0,0 +1,178 @@
#!/usr/bin/env bash
set -euo pipefail
print_failed_bazel_test_logs=0
use_node_test_env=0
while [[ $# -gt 0 ]]; do
case "$1" in
--print-failed-test-logs)
print_failed_bazel_test_logs=1
shift
;;
--use-node-test-env)
use_node_test_env=1
shift
;;
--)
shift
break
;;
*)
echo "Unknown option: $1" >&2
exit 1
;;
esac
done
if [[ $# -eq 0 ]]; then
echo "Usage: $0 [--print-failed-test-logs] [--use-node-test-env] -- <bazel args> -- <targets>" >&2
exit 1
fi
bazel_startup_args=()
if [[ -n "${BAZEL_OUTPUT_USER_ROOT:-}" ]]; then
bazel_startup_args+=("--output_user_root=${BAZEL_OUTPUT_USER_ROOT}")
fi
ci_config=ci-linux
case "${RUNNER_OS:-}" in
macOS)
ci_config=ci-macos
;;
Windows)
ci_config=ci-windows
;;
esac
print_bazel_test_log_tails() {
local console_log="$1"
local testlogs_dir
local -a bazel_info_cmd=(bazel)
if (( ${#bazel_startup_args[@]} > 0 )); then
bazel_info_cmd+=("${bazel_startup_args[@]}")
fi
testlogs_dir="$("${bazel_info_cmd[@]}" info bazel-testlogs 2>/dev/null || echo bazel-testlogs)"
local failed_targets=()
while IFS= read -r target; do
failed_targets+=("$target")
done < <(
grep -E '^FAIL: //' "$console_log" \
| sed -E 's#^FAIL: (//[^ ]+).*#\1#' \
| sort -u
)
if [[ ${#failed_targets[@]} -eq 0 ]]; then
echo "No failed Bazel test targets were found in console output."
return
fi
for target in "${failed_targets[@]}"; do
local rel_path="${target#//}"
rel_path="${rel_path/:/\/}"
local test_log="${testlogs_dir}/${rel_path}/test.log"
echo "::group::Bazel test log tail for ${target}"
if [[ -f "$test_log" ]]; then
tail -n 200 "$test_log"
else
echo "Missing test log: $test_log"
fi
echo "::endgroup::"
done
}
bazel_args=()
bazel_targets=()
found_target_separator=0
for arg in "$@"; do
if [[ "$arg" == "--" && $found_target_separator -eq 0 ]]; then
found_target_separator=1
continue
fi
if [[ $found_target_separator -eq 0 ]]; then
bazel_args+=("$arg")
else
bazel_targets+=("$arg")
fi
done
if [[ ${#bazel_args[@]} -eq 0 || ${#bazel_targets[@]} -eq 0 ]]; then
echo "Expected Bazel args and targets separated by --" >&2
exit 1
fi
if [[ $use_node_test_env -eq 1 && "${RUNNER_OS:-}" != "Windows" ]]; then
# Bazel test sandboxes on macOS may resolve an older Homebrew `node`
# before the `actions/setup-node` runtime on PATH.
node_bin="$(which node)"
bazel_args+=("--test_env=CODEX_JS_REPL_NODE_PATH=${node_bin}")
fi
bazel_console_log="$(mktemp)"
trap 'rm -f "$bazel_console_log"' EXIT
bazel_cmd=(bazel)
if (( ${#bazel_startup_args[@]} > 0 )); then
bazel_cmd+=("${bazel_startup_args[@]}")
fi
if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then
echo "BuildBuddy API key is available; using remote Bazel configuration."
# Work around Bazel 9 remote repo contents cache / overlay materialization failures
# seen in CI (for example "is not a symlink" or permission errors while
# materializing external repos such as rules_perl). We still use BuildBuddy for
# remote execution/cache; this only disables the startup-level repo contents cache.
set +e
"${bazel_cmd[@]}" \
--noexperimental_remote_repo_contents_cache \
"${bazel_args[@]}" \
"--config=${ci_config}" \
"--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" \
-- \
"${bazel_targets[@]}" \
2>&1 | tee "$bazel_console_log"
bazel_status=${PIPESTATUS[0]}
set -e
else
echo "BuildBuddy API key is not available; using local Bazel configuration."
# Keep fork/community PRs on Bazel but disable remote services that are
# configured in .bazelrc and require auth.
#
# Flag docs:
# - Command-line reference: https://bazel.build/reference/command-line-reference
# - Remote caching overview: https://bazel.build/remote/caching
# - Remote execution overview: https://bazel.build/remote/rbe
# - Build Event Protocol overview: https://bazel.build/remote/bep
#
# --noexperimental_remote_repo_contents_cache:
# disable remote repo contents cache enabled in .bazelrc startup options.
# https://bazel.build/reference/command-line-reference#startup_options-flag--experimental_remote_repo_contents_cache
# --remote_cache= and --remote_executor=:
# clear remote cache/execution endpoints configured in .bazelrc.
# https://bazel.build/reference/command-line-reference#common_options-flag--remote_cache
# https://bazel.build/reference/command-line-reference#common_options-flag--remote_executor
set +e
"${bazel_cmd[@]}" \
--noexperimental_remote_repo_contents_cache \
"${bazel_args[@]}" \
--remote_cache= \
--remote_executor= \
-- \
"${bazel_targets[@]}" \
2>&1 | tee "$bazel_console_log"
bazel_status=${PIPESTATUS[0]}
set -e
fi
if [[ ${bazel_status:-0} -ne 0 ]]; then
if [[ $print_failed_bazel_test_logs -eq 1 ]]; then
print_bazel_test_log_tails "$bazel_console_log"
fi
exit "$bazel_status"
fi

View File

@@ -1,4 +1,4 @@
name: Bazel (experimental)
name: Bazel
# Note this workflow was originally derived from:
# https://github.com/cerisier/toolchains_llvm_bootstrapped/blob/main/.github/workflows/ci.yaml
@@ -50,181 +50,91 @@ jobs:
steps:
- uses: actions/checkout@v6
- name: Set up Node.js for js_repl tests
uses: actions/setup-node@v6
- name: Set up Bazel CI
id: setup_bazel
uses: ./.github/actions/setup-bazel-ci
with:
node-version-file: codex-rs/node-version.txt
# Some integration tests rely on DotSlash being installed.
# See https://github.com/openai/codex/pull/7617.
- name: Install DotSlash
uses: facebook/install-dotslash@v2
- name: Make DotSlash available in PATH (Unix)
if: runner.os != 'Windows'
run: cp "$(which dotslash)" /usr/local/bin
- name: Make DotSlash available in PATH (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: Copy-Item (Get-Command dotslash).Source -Destination "$env:LOCALAPPDATA\Microsoft\WindowsApps\dotslash.exe"
# Install Bazel via Bazelisk
- name: Set up Bazel
uses: bazelbuild/setup-bazelisk@v3
target: ${{ matrix.target }}
install-test-prereqs: "true"
- name: Check MODULE.bazel.lock is up to date
if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu'
shell: bash
run: ./scripts/check-module-bazel-lock.sh
# Restore bazel repository cache so we don't have to redownload all the external dependencies
# on every CI run.
- name: Restore bazel repository cache
id: cache_bazel_repository_restore
uses: actions/cache/restore@v5
with:
path: |
~/.cache/bazel-repo-cache
key: bazel-cache-${{ matrix.target }}-${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }}
restore-keys: |
bazel-cache-${{ matrix.target }}
- name: Configure Bazel startup args (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
# Use a very short path to reduce argv/path length issues.
"BAZEL_STARTUP_ARGS=--output_user_root=C:\" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
- name: bazel test //...
env:
BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }}
shell: bash
run: |
set -o pipefail
bazel_console_log="$(mktemp)"
print_failed_bazel_test_logs() {
local console_log="$1"
local testlogs_dir
testlogs_dir="$(bazel $BAZEL_STARTUP_ARGS info bazel-testlogs 2>/dev/null || echo bazel-testlogs)"
local failed_targets=()
while IFS= read -r target; do
failed_targets+=("$target")
done < <(
grep -E '^FAIL: //' "$console_log" \
| sed -E 's#^FAIL: (//[^ ]+).*#\1#' \
| sort -u
)
if [[ ${#failed_targets[@]} -eq 0 ]]; then
echo "No failed Bazel test targets were found in console output."
return
fi
for target in "${failed_targets[@]}"; do
local rel_path="${target#//}"
rel_path="${rel_path/:/\/}"
local test_log="${testlogs_dir}/${rel_path}/test.log"
echo "::group::Bazel test log tail for ${target}"
if [[ -f "$test_log" ]]; then
tail -n 200 "$test_log"
else
echo "Missing test log: $test_log"
fi
echo "::endgroup::"
done
}
bazel_args=(
test
--test_verbose_timeout_warnings
--build_metadata=COMMIT_SHA=$(git rev-parse HEAD)
)
bazel_targets=(
//...
# Keep V8 out of the ordinary Bazel CI path. Only the dedicated
# canary and release workflows should build `third_party/v8`.
# Keep V8 out of the ordinary Bazel CI path. Only the dedicated
# canary and release workflows should build `third_party/v8`.
./.github/scripts/run-bazel-ci.sh \
--print-failed-test-logs \
--use-node-test-env \
-- \
test \
--test_verbose_timeout_warnings \
--build_metadata=COMMIT_SHA=${GITHUB_SHA} \
-- \
//... \
-//third_party/v8:all
)
if [[ "${RUNNER_OS:-}" != "Windows" ]]; then
# Bazel test sandboxes on macOS may resolve an older Homebrew `node`
# before the `actions/setup-node` runtime on PATH.
node_bin="$(which node)"
bazel_args+=("--test_env=CODEX_JS_REPL_NODE_PATH=${node_bin}")
fi
ci_config=ci-linux
if [[ "${RUNNER_OS:-}" == "macOS" ]]; then
ci_config=ci-macos
elif [[ "${RUNNER_OS:-}" == "Windows" ]]; then
ci_config=ci-windows
fi
if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then
echo "BuildBuddy API key is available; using remote Bazel configuration."
# Work around Bazel 9 remote repo contents cache / overlay materialization failures
# seen in CI (for example "is not a symlink" or permission errors while
# materializing external repos such as rules_perl). We still use BuildBuddy for
# remote execution/cache; this only disables the startup-level repo contents cache.
set +e
bazel $BAZEL_STARTUP_ARGS \
--noexperimental_remote_repo_contents_cache \
"${bazel_args[@]}" \
"--config=${ci_config}" \
"--remote_header=x-buildbuddy-api-key=$BUILDBUDDY_API_KEY" \
-- \
"${bazel_targets[@]}" \
2>&1 | tee "$bazel_console_log"
bazel_status=${PIPESTATUS[0]}
set -e
else
echo "BuildBuddy API key is not available; using local Bazel configuration."
# Keep fork/community PRs on Bazel but disable remote services that are
# configured in .bazelrc and require auth.
#
# Flag docs:
# - Command-line reference: https://bazel.build/reference/command-line-reference
# - Remote caching overview: https://bazel.build/remote/caching
# - Remote execution overview: https://bazel.build/remote/rbe
# - Build Event Protocol overview: https://bazel.build/remote/bep
#
# --noexperimental_remote_repo_contents_cache:
# disable remote repo contents cache enabled in .bazelrc startup options.
# https://bazel.build/reference/command-line-reference#startup_options-flag--experimental_remote_repo_contents_cache
# --remote_cache= and --remote_executor=:
# clear remote cache/execution endpoints configured in .bazelrc.
# https://bazel.build/reference/command-line-reference#common_options-flag--remote_cache
# https://bazel.build/reference/command-line-reference#common_options-flag--remote_executor
set +e
bazel $BAZEL_STARTUP_ARGS \
--noexperimental_remote_repo_contents_cache \
"${bazel_args[@]}" \
--remote_cache= \
--remote_executor= \
-- \
"${bazel_targets[@]}" \
2>&1 | tee "$bazel_console_log"
bazel_status=${PIPESTATUS[0]}
set -e
fi
if [[ ${bazel_status:-0} -ne 0 ]]; then
print_failed_bazel_test_logs "$bazel_console_log"
exit "$bazel_status"
fi
# Save bazel repository cache explicitly; make non-fatal so cache uploading
# never fails the overall job. Only save when key wasn't hit.
- name: Save bazel repository cache
if: always() && !cancelled() && steps.cache_bazel_repository_restore.outputs.cache-hit != 'true'
if: always() && !cancelled() && steps.setup_bazel.outputs.cache-hit != 'true'
continue-on-error: true
uses: actions/cache/save@v5
with:
path: |
~/.cache/bazel-repo-cache
key: bazel-cache-${{ matrix.target }}-${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }}
clippy:
strategy:
fail-fast: false
matrix:
include:
# Keep Linux lint coverage on x64 and add the arm64 macOS path that
# the Bazel test job already exercises.
- os: ubuntu-24.04
target: x86_64-unknown-linux-gnu
- os: macos-15-xlarge
target: aarch64-apple-darwin
runs-on: ${{ matrix.os }}
name: Bazel clippy on ${{ matrix.os }} for ${{ matrix.target }}
steps:
- uses: actions/checkout@v6
- name: Set up Bazel CI
id: setup_bazel
uses: ./.github/actions/setup-bazel-ci
with:
target: ${{ matrix.target }}
- name: bazel build --config=clippy //codex-rs/...
env:
BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }}
shell: bash
run: |
# Keep the initial Bazel clippy scope on codex-rs and out of the
# V8 proof-of-concept target for now.
./.github/scripts/run-bazel-ci.sh \
-- \
build \
--config=clippy \
--build_metadata=COMMIT_SHA=${GITHUB_SHA} \
--build_metadata=TAG_job=clippy \
-- \
//codex-rs/... \
-//codex-rs/v8-poc:all
# Save bazel repository cache explicitly; make non-fatal so cache uploading
# never fails the overall job. Only save when key wasn't hit.
- name: Save bazel repository cache
if: always() && !cancelled() && steps.setup_bazel.outputs.cache-hit != 'true'
continue-on-error: true
uses: actions/cache/save@v5
with:

View File

@@ -1,3 +1,4 @@
exports_files([
"clippy.toml",
"node-version.txt",
])

1
codex-rs/Cargo.lock generated
View File

@@ -2614,6 +2614,7 @@ dependencies = [
name = "codex-tools"
version = "0.0.0"
dependencies = [
"codex-protocol",
"pretty_assertions",
"rmcp",
"serde",

View File

@@ -3,11 +3,10 @@ use crate::agent::registry::AgentMetadata;
use crate::agent::registry::AgentRegistry;
use crate::agent::role::DEFAULT_ROLE_NAME;
use crate::agent::role::resolve_role_config;
use crate::agent::status::is_final;
use crate::codex_thread::ThreadConfigSnapshot;
use crate::context_manager::is_user_turn_boundary;
use crate::error::CodexErr;
use crate::error::Result as CodexResult;
use crate::event_mapping::parse_turn_item;
use crate::find_archived_thread_path_by_id_str;
use crate::find_thread_path_by_id_str;
use crate::rollout::RolloutRecorder;
@@ -19,10 +18,7 @@ use crate::thread_manager::ThreadManagerState;
use codex_features::Feature;
use codex_protocol::AgentPath;
use codex_protocol::ThreadId;
use codex_protocol::items::TurnItem;
use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::InterAgentCommunication;
@@ -117,30 +113,36 @@ impl AgentControl {
pub(crate) async fn spawn_agent(
&self,
config: crate::config::Config,
items: Vec<UserInput>,
initial_operation: Op,
session_source: Option<SessionSource>,
) -> CodexResult<ThreadId> {
Ok(self
.spawn_agent_internal(config, items, session_source, SpawnAgentOptions::default())
.spawn_agent_internal(
config,
initial_operation,
session_source,
SpawnAgentOptions::default(),
)
.await?
.thread_id)
}
/// Spawn an agent thread with some metadata.
pub(crate) async fn spawn_agent_with_metadata(
&self,
config: crate::config::Config,
items: Vec<UserInput>,
initial_operation: Op,
session_source: Option<SessionSource>,
options: SpawnAgentOptions,
options: SpawnAgentOptions, // TODO(jif) drop with new fork.
) -> CodexResult<LiveAgent> {
self.spawn_agent_internal(config, items, session_source, options)
self.spawn_agent_internal(config, initial_operation, session_source, options)
.await
}
async fn spawn_agent_internal(
&self,
config: crate::config::Config,
items: Vec<UserInput>,
initial_operation: Op,
session_source: Option<SessionSource>,
options: SpawnAgentOptions,
) -> CodexResult<LiveAgent> {
@@ -269,7 +271,19 @@ impl AgentControl {
)
.await;
self.send_input(new_thread.thread_id, items).await?;
self.send_input(new_thread.thread_id, initial_operation)
.await?;
let child_reference = agent_metadata
.agent_path
.as_ref()
.map(ToString::to_string)
.unwrap_or_else(|| new_thread.thread_id.to_string());
self.maybe_start_completion_watcher(
new_thread.thread_id,
notification_source,
child_reference,
agent_metadata.agent_path.clone(),
);
Ok(LiveAgent {
thread_id: new_thread.thread_id,
@@ -431,6 +445,17 @@ impl AgentControl {
// Resumed threads are re-registered in-memory and need the same listener
// attachment path as freshly spawned threads.
state.notify_thread_created(resumed_thread.thread_id);
let child_reference = agent_metadata
.agent_path
.as_ref()
.map(ToString::to_string)
.unwrap_or_else(|| resumed_thread.thread_id.to_string());
self.maybe_start_completion_watcher(
resumed_thread.thread_id,
Some(notification_source.clone()),
child_reference,
agent_metadata.agent_path.clone(),
);
self.persist_thread_spawn_edge_for_source(
resumed_thread.thread.as_ref(),
resumed_thread.thread_id,
@@ -445,23 +470,15 @@ impl AgentControl {
pub(crate) async fn send_input(
&self,
agent_id: ThreadId,
items: Vec<UserInput>,
initial_operation: Op,
) -> CodexResult<String> {
let last_task_message = render_input_preview(&items);
let last_task_message = render_input_preview(&initial_operation);
let state = self.upgrade()?;
let result = self
.handle_thread_request_result(
agent_id,
&state,
state
.send_op(
agent_id,
Op::UserInput {
items,
final_output_json_schema: None,
},
)
.await,
state.send_op(agent_id, initial_operation).await,
)
.await;
if result.is_ok() {
@@ -743,10 +760,7 @@ impl AgentControl {
.as_ref()
.map(ToString::to_string)
.unwrap_or_else(|| thread_id.to_string());
let last_task_message = match metadata.last_task_message.clone() {
Some(last_task_message) => Some(last_task_message),
None => last_task_message_for_thread(thread.as_ref()).await,
};
let last_task_message = metadata.last_task_message.clone();
agents.push(ListedAgent {
agent_name,
agent_status: thread.agent_status().await,
@@ -757,53 +771,83 @@ impl AgentControl {
Ok(agents)
}
pub(crate) async fn forward_child_completion_to_parent(
/// Starts a detached watcher for sub-agents spawned from another thread.
///
/// This is only enabled for `SubAgentSource::ThreadSpawn`, where a parent thread exists and
/// can receive completion notifications.
fn maybe_start_completion_watcher(
&self,
parent_thread_id: ThreadId,
child_thread_id: ThreadId,
session_source: Option<SessionSource>,
child_reference: String,
child_agent_path: Option<AgentPath>,
status: AgentStatus,
) {
let Ok(state) = self.upgrade() else {
let Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id, ..
})) = session_source
else {
return;
};
let child_thread = state.get_thread(child_thread_id).await.ok();
let message = format_subagent_notification_message(child_reference.as_str(), &status);
if child_agent_path.is_some()
&& child_thread
.as_ref()
.map(|thread| thread.enabled(Feature::MultiAgentV2))
.unwrap_or(true)
{
let Some(child_agent_path) = child_agent_path else {
let control = self.clone();
tokio::spawn(async move {
let status = match control.subscribe_status(child_thread_id).await {
Ok(mut status_rx) => {
let mut status = status_rx.borrow().clone();
while !is_final(&status) {
if status_rx.changed().await.is_err() {
status = control.get_status(child_thread_id).await;
break;
}
status = status_rx.borrow().clone();
}
status
}
Err(_) => control.get_status(child_thread_id).await,
};
if !is_final(&status) {
return;
}
let Ok(state) = control.upgrade() else {
return;
};
let Some(parent_agent_path) = child_agent_path
.as_str()
.rsplit_once('/')
.and_then(|(parent, _)| AgentPath::try_from(parent).ok())
else {
let child_thread = state.get_thread(child_thread_id).await.ok();
let message = format_subagent_notification_message(child_reference.as_str(), &status);
if child_agent_path.is_some()
&& child_thread
.as_ref()
.map(|thread| thread.enabled(Feature::MultiAgentV2))
.unwrap_or(true)
{
let Some(child_agent_path) = child_agent_path.clone() else {
return;
};
let Some(parent_agent_path) = child_agent_path
.as_str()
.rsplit_once('/')
.and_then(|(parent, _)| AgentPath::try_from(parent).ok())
else {
return;
};
let communication = InterAgentCommunication::new(
child_agent_path,
parent_agent_path,
Vec::new(),
message,
/*trigger_turn*/ false,
);
let _ = control
.send_inter_agent_communication(parent_thread_id, communication)
.await;
return;
}
let Ok(parent_thread) = state.get_thread(parent_thread_id).await else {
return;
};
let communication = InterAgentCommunication::new(
child_agent_path,
parent_agent_path,
Vec::new(),
message,
/*trigger_turn*/ false,
);
let _ = self
.send_inter_agent_communication(parent_thread_id, communication)
parent_thread
.inject_user_message_without_turn(message)
.await;
return;
}
let Ok(parent_thread) = state.get_thread(parent_thread_id).await else {
return;
};
parent_thread
.inject_user_message_without_turn(message)
.await;
});
}
#[allow(clippy::too_many_arguments)]
@@ -1019,79 +1063,23 @@ fn agent_matches_prefix(agent_path: Option<&AgentPath>, prefix: &AgentPath) -> b
})
}
async fn last_task_message_for_thread(thread: &crate::CodexThread) -> Option<String> {
let pending_input = thread.codex.session.pending_input_snapshot().await;
if let Some(message) = pending_input
.iter()
.rev()
.find_map(last_task_message_from_input_item)
{
return Some(message);
pub(crate) fn render_input_preview(initial_operation: &Op) -> String {
match initial_operation {
Op::UserInput { items, .. } => items
.iter()
.map(|item| match item {
UserInput::Text { text, .. } => text.clone(),
UserInput::Image { .. } => "[image]".to_string(),
UserInput::LocalImage { path } => format!("[local_image:{}]", path.display()),
UserInput::Skill { name, path } => format!("[skill:${name}]({})", path.display()),
UserInput::Mention { name, path } => format!("[mention:${name}]({path})"),
_ => "[input]".to_string(),
})
.collect::<Vec<_>>()
.join("\n"),
Op::InterAgentCommunication { communication } => communication.content.clone(),
_ => String::new(),
}
let queued_input = thread
.codex
.session
.queued_response_items_for_next_turn_snapshot()
.await;
if let Some(message) = queued_input
.iter()
.rev()
.find_map(last_task_message_from_input_item)
{
return Some(message);
}
let history = thread.codex.session.clone_history().await;
history
.raw_items()
.iter()
.rev()
.find_map(last_task_message_from_item)
}
fn last_task_message_from_input_item(item: &ResponseInputItem) -> Option<String> {
let response_item: ResponseItem = item.clone().into();
last_task_message_from_item(&response_item)
}
fn last_task_message_from_item(item: &ResponseItem) -> Option<String> {
if !is_user_turn_boundary(item) {
return None;
}
match item {
ResponseItem::Message { role, .. } if role == "user" => {
let Some(TurnItem::UserMessage(message)) = parse_turn_item(item) else {
return None;
};
Some(render_input_preview(&message.content))
}
ResponseItem::Message { content, .. } => match content.as_slice() {
[ContentItem::InputText { text }] | [ContentItem::OutputText { text }] => {
serde_json::from_str::<InterAgentCommunication>(text)
.ok()
.map(|communication| communication.content)
}
_ => None,
},
_ => None,
}
}
fn render_input_preview(items: &[UserInput]) -> String {
items
.iter()
.map(|item| match item {
UserInput::Text { text, .. } => text.clone(),
UserInput::Image { .. } => "[image]".to_string(),
UserInput::LocalImage { path } => format!("[local_image:{}]", path.display()),
UserInput::Skill { name, path } => format!("[skill:${name}]({})", path.display()),
UserInput::Mention { name, path } => format!("[mention:${name}]({path})"),
_ => "[input]".to_string(),
})
.collect::<Vec<_>>()
.join("\n")
}
fn thread_spawn_depth(session_source: &SessionSource) -> Option<i32> {

View File

@@ -54,11 +54,12 @@ async fn test_config() -> (TempDir, Config) {
test_config_with_cli_overrides(Vec::new()).await
}
fn text_input(text: &str) -> Vec<UserInput> {
fn text_input(text: &str) -> Op {
vec![UserInput::Text {
text: text.to_string(),
text_elements: Vec::new(),
}]
.into()
}
struct AgentControlHarness {
@@ -217,7 +218,8 @@ async fn send_input_errors_when_manager_dropped() {
vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}],
}]
.into(),
)
.await
.expect_err("send_input should fail without a manager");
@@ -321,7 +323,8 @@ async fn send_input_errors_when_thread_missing() {
vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}],
}]
.into(),
)
.await
.expect_err("send_input should fail for missing thread");
@@ -387,7 +390,8 @@ async fn send_input_submits_user_message() {
vec![UserInput::Text {
text: "hello from tests".to_string(),
text_elements: Vec::new(),
}],
}]
.into(),
)
.await
.expect("send_input should succeed");
@@ -1008,7 +1012,7 @@ async fn resume_agent_releases_slot_after_resume_failure() {
}
#[tokio::test]
async fn spawn_child_turn_completion_notifies_parent_history() {
async fn spawn_child_completion_notifies_parent_history() {
let harness = AgentControlHarness::new().await;
let (parent_thread_id, parent_thread) = harness.start_thread().await;
@@ -1033,18 +1037,10 @@ async fn spawn_child_turn_completion_notifies_parent_history() {
.get_thread(child_thread_id)
.await
.expect("child thread should exist");
let child_turn = child_thread.codex.session.new_default_turn().await;
child_thread
.codex
.session
.send_event(
child_turn.as_ref(),
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: child_turn.sub_id.clone(),
last_agent_message: Some("done".to_string()),
}),
)
.await;
let _ = child_thread
.submit(Op::Shutdown {})
.await
.expect("child shutdown should submit");
assert_eq!(wait_for_subagent_notification(&parent_thread).await, true);
}
@@ -1157,22 +1153,9 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() {
let (worker_thread_id, _worker_thread) = harness.start_thread().await;
let mut tester_config = harness.config.clone();
let _ = tester_config.features.enable(Feature::MultiAgentV2);
let worker_path = AgentPath::root().join("worker_a").expect("worker path");
let tester_path = worker_path.join("tester").expect("tester path");
let tester_thread_id = harness
.control
.spawn_agent_with_metadata(
tester_config,
text_input("seed task"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: worker_thread_id,
depth: 2,
agent_path: Some(tester_path.clone()),
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
SpawnAgentOptions::default(),
)
.manager
.start_thread(tester_config)
.await
.expect("tester thread should start")
.thread_id;
@@ -1181,6 +1164,20 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() {
.get_thread(tester_thread_id)
.await
.expect("tester thread should exist");
let worker_path = AgentPath::root().join("worker_a").expect("worker path");
let tester_path = worker_path.join("tester").expect("tester path");
harness.control.maybe_start_completion_watcher(
tester_thread_id,
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: worker_thread_id,
depth: 2,
agent_path: Some(tester_path.clone()),
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
tester_path.to_string(),
Some(tester_path.clone()),
);
let tester_turn = tester_thread.codex.session.new_default_turn().await;
tester_thread
.codex
@@ -1225,7 +1222,7 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() {
}
})
.await
.expect("turn completion should queue a direct-parent message");
.expect("completion watcher should queue a direct-parent message");
let root_history_items = root_thread
.codex
@@ -1247,118 +1244,44 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() {
}
#[tokio::test]
async fn multi_agent_v2_turn_completion_notifies_parent_for_later_turns() {
async fn completion_watcher_notifies_parent_when_child_is_missing() {
let harness = AgentControlHarness::new().await;
let (_root_thread_id, _root_thread) = harness.start_thread().await;
let (worker_thread_id, _worker_thread) = harness.start_thread().await;
let mut tester_config = harness.config.clone();
let _ = tester_config.features.enable(Feature::MultiAgentV2);
let worker_path = AgentPath::root().join("worker_a").expect("worker path");
let tester_path = worker_path.join("tester").expect("tester path");
let tester_thread_id = harness
.control
.spawn_agent_with_metadata(
tester_config,
text_input("seed task"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: worker_thread_id,
depth: 2,
agent_path: Some(tester_path.clone()),
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
SpawnAgentOptions::default(),
)
let (parent_thread_id, parent_thread) = harness.start_thread().await;
let child_thread_id = ThreadId::new();
harness.control.maybe_start_completion_watcher(
child_thread_id,
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
child_thread_id.to_string(),
None,
);
assert_eq!(wait_for_subagent_notification(&parent_thread).await, true);
let history_items = parent_thread
.codex
.session
.clone_history()
.await
.expect("tester thread should start")
.thread_id;
let tester_thread = harness
.manager
.get_thread(tester_thread_id)
.await
.expect("tester thread should exist");
let first_turn = tester_thread.codex.session.new_default_turn().await;
tester_thread
.codex
.session
.send_event(
first_turn.as_ref(),
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: first_turn.sub_id.clone(),
last_agent_message: Some("done once".to_string()),
}),
)
.await;
let second_turn = tester_thread.codex.session.new_default_turn().await;
tester_thread
.codex
.session
.send_event(
second_turn.as_ref(),
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: second_turn.sub_id.clone(),
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
)
.await;
tester_thread
.codex
.session
.send_event(
second_turn.as_ref(),
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: second_turn.sub_id.clone(),
last_agent_message: Some("done twice".to_string()),
}),
)
.await;
let expected_messages = [
crate::session_prefix::format_subagent_notification_message(
tester_path.as_str(),
&AgentStatus::Completed(Some("done once".to_string())),
.raw_items()
.to_vec();
assert_eq!(
history_contains_text(
&history_items,
&format!("\"agent_path\":\"{child_thread_id}\"")
),
crate::session_prefix::format_subagent_notification_message(
tester_path.as_str(),
&AgentStatus::Completed(Some("done twice".to_string())),
),
];
timeout(Duration::from_secs(5), async {
loop {
let count = harness
.manager
.captured_ops()
.into_iter()
.filter(|(thread_id, op)| {
*thread_id == worker_thread_id
&& expected_messages.iter().any(|message| {
matches!(
op,
Op::InterAgentCommunication { communication }
if communication
== &InterAgentCommunication::new(
tester_path.clone(),
worker_path.clone(),
Vec::new(),
message.clone(),
false,
)
)
})
})
.count();
if count == expected_messages.len() {
break;
}
sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("turn completion should notify the parent for each completed turn");
true
);
assert_eq!(
history_contains_text(&history_items, "\"status\":\"not_found\""),
true
);
}
#[tokio::test]

View File

@@ -2629,7 +2629,6 @@ impl Session {
msg,
};
self.send_event_raw(event).await;
self.maybe_start_parent_turn_completion_notification(turn_context, &legacy_source);
self.maybe_mirror_event_text_to_realtime(&legacy_source)
.await;
self.maybe_clear_realtime_handoff_for_event(&legacy_source)
@@ -2645,45 +2644,6 @@ impl Session {
}
}
fn maybe_start_parent_turn_completion_notification(
&self,
turn_context: &TurnContext,
msg: &EventMsg,
) {
let Some(status @ (AgentStatus::Completed(_) | AgentStatus::Errored(_))) =
agent_status_from_event(msg)
else {
return;
};
let SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
agent_path,
..
}) = &turn_context.session_source
else {
return;
};
let child_reference = agent_path
.as_ref()
.map(ToString::to_string)
.unwrap_or_else(|| self.conversation_id.to_string());
let agent_control = self.services.agent_control.clone();
let parent_thread_id = *parent_thread_id;
let child_thread_id = self.conversation_id;
let child_agent_path = agent_path.clone();
tokio::spawn(async move {
agent_control
.forward_child_completion_to_parent(
parent_thread_id,
child_thread_id,
child_reference,
child_agent_path,
status,
)
.await;
});
}
async fn maybe_mirror_event_text_to_realtime(&self, msg: &EventMsg) {
let Some(text) = realtime_text_for_event(msg) else {
return;
@@ -4020,17 +3980,6 @@ impl Session {
}
}
pub(crate) async fn pending_input_snapshot(&self) -> Vec<ResponseInputItem> {
let active = self.active_turn.lock().await;
match active.as_ref() {
Some(at) => {
let ts = at.turn_state.lock().await;
ts.pending_input_snapshot()
}
None => Vec::with_capacity(0),
}
}
/// Queue response items to be injected into the next active turn created for this session.
pub(crate) async fn queue_response_items_for_next_turn(&self, items: Vec<ResponseInputItem>) {
if items.is_empty() {
@@ -4045,12 +3994,6 @@ impl Session {
std::mem::take(&mut *self.idle_pending_input.lock().await)
}
pub(crate) async fn queued_response_items_for_next_turn_snapshot(
&self,
) -> Vec<ResponseInputItem> {
self.idle_pending_input.lock().await.clone()
}
pub(crate) async fn has_queued_response_items_for_next_turn(&self) -> bool {
!self.idle_pending_input.lock().await.is_empty()
}

View File

@@ -132,7 +132,7 @@ pub(super) async fn run(session: &Arc<Session>, config: Arc<Config>) {
let thread_id = match session
.services
.agent_control
.spawn_agent(agent_config, prompt, Some(source))
.spawn_agent(agent_config, prompt.into(), Some(source))
.await
{
Ok(thread_id) => thread_id,

View File

@@ -198,10 +198,6 @@ impl TurnState {
}
}
pub(crate) fn pending_input_snapshot(&self) -> Vec<ResponseInputItem> {
self.pending_input.clone()
}
pub(crate) fn has_pending_input(&self) -> bool {
!self.pending_input.is_empty()
}

View File

@@ -1,3 +1,4 @@
use std::borrow::Cow;
use std::sync::Arc;
use async_trait::async_trait;
@@ -14,6 +15,7 @@ use codex_protocol::protocol::ExitedReviewModeEvent;
use codex_protocol::protocol::ItemCompletedEvent;
use codex_protocol::protocol::ReviewOutputEvent;
use codex_protocol::protocol::SubAgentSource;
use codex_utils_template::Template;
use tokio_util::sync::CancellationToken;
use crate::codex::Session;
@@ -25,10 +27,18 @@ use crate::review_format::render_review_output_text;
use crate::state::TaskKind;
use codex_features::Feature;
use codex_protocol::user_input::UserInput;
use std::sync::LazyLock;
use super::SessionTask;
use super::SessionTaskContext;
static REVIEW_EXIT_SUCCESS_TEMPLATE: LazyLock<Template> = LazyLock::new(|| {
let normalized =
normalize_review_template_line_endings(crate::client_common::REVIEW_EXIT_SUCCESS_TMPL);
Template::parse(normalized.as_ref())
.unwrap_or_else(|err| panic!("review exit success template must parse: {err}"))
});
#[derive(Clone, Copy)]
pub(crate) struct ReviewTask;
@@ -220,12 +230,14 @@ pub(crate) async fn exit_review_mode(
let block = format_review_findings_block(&out.findings, /*selection*/ None);
findings_str.push_str(&format!("\n{block}"));
}
let rendered =
crate::client_common::REVIEW_EXIT_SUCCESS_TMPL.replace("{results}", &findings_str);
let rendered = render_review_exit_success(&findings_str);
let assistant_message = render_review_output_text(&out);
(rendered, assistant_message)
} else {
let rendered = crate::client_common::REVIEW_EXIT_INTERRUPTED_TMPL.to_string();
let rendered = normalize_review_template_line_endings(
crate::client_common::REVIEW_EXIT_INTERRUPTED_TMPL,
)
.into_owned();
let assistant_message =
"Review was interrupted. Please re-run /review and wait for it to complete."
.to_string();
@@ -271,3 +283,40 @@ pub(crate) async fn exit_review_mode(
// file creation + git metadata collection cannot delay client-facing items.
session.ensure_rollout_materialized().await;
}
fn render_review_exit_success(results: &str) -> String {
REVIEW_EXIT_SUCCESS_TEMPLATE
.render([("results", results)])
.unwrap_or_else(|err| panic!("review exit success template must render: {err}"))
}
fn normalize_review_template_line_endings(template: &str) -> Cow<'_, str> {
if template.contains('\r') {
Cow::Owned(template.replace("\r\n", "\n").replace('\r', "\n"))
} else {
Cow::Borrowed(template)
}
}
#[cfg(test)]
mod tests {
use super::normalize_review_template_line_endings;
use super::render_review_exit_success;
use pretty_assertions::assert_eq;
#[test]
fn render_review_exit_success_replaces_results_placeholder() {
assert_eq!(
render_review_exit_success("Finding A\nFinding B"),
"<user_action>\n <context>User initiated a review task. Here's the full review output from reviewer model. User may select one or more comments to resolve.</context>\n <action>review</action>\n <results>\n Finding A\nFinding B\n </results>\n </user_action>\n"
);
}
#[test]
fn normalize_review_template_line_endings_rewrites_crlf() {
assert_eq!(
normalize_review_template_line_endings("<user_action>\r\n <results>\r\n None.\r\n"),
"<user_action>\n <results>\n None.\n"
);
}
}

View File

@@ -633,7 +633,7 @@ async fn run_agent_job_loop(
.agent_control
.spawn_agent(
options.spawn_config.clone(),
items,
items.into(),
Some(SessionSource::SubAgent(SubAgentSource::Other(format!(
"agent_job:{job_id}"
)))),

View File

@@ -1,4 +1,5 @@
use super::*;
use crate::agent::control::render_input_preview;
pub(crate) struct Handler;
@@ -26,7 +27,7 @@ impl ToolHandler for Handler {
let args: SendInputArgs = parse_arguments(&arguments)?;
let receiver_thread_id = parse_agent_id_target(&args.target)?;
let input_items = parse_collab_input(args.message, args.items)?;
let prompt = input_preview(&input_items);
let prompt = render_input_preview(&input_items);
let receiver_agent = session
.services
.agent_control

View File

@@ -1,5 +1,6 @@
use super::*;
use crate::agent::control::SpawnAgentOptions;
use crate::agent::control::render_input_preview;
use crate::agent::role::DEFAULT_ROLE_NAME;
use crate::agent::role::apply_role_to_config;
@@ -36,7 +37,7 @@ impl ToolHandler for Handler {
.map(str::trim)
.filter(|role| !role.is_empty());
let input_items = parse_collab_input(args.message, args.items)?;
let prompt = input_preview(&input_items);
let prompt = render_input_preview(&input_items);
let session_source = turn.session_source.clone();
let child_depth = next_thread_spawn_depth(&session_source);
let max_depth = turn.config.agent_max_depth;

View File

@@ -17,6 +17,7 @@ use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::openai_models::ReasoningEffortPreset;
use codex_protocol::protocol::CollabAgentRef;
use codex_protocol::protocol::CollabAgentStatusEntry;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::user_input::UserInput;
@@ -161,7 +162,7 @@ pub(crate) fn thread_spawn_source(
pub(crate) fn parse_collab_input(
message: Option<String>,
items: Option<Vec<UserInput>>,
) -> Result<Vec<UserInput>, FunctionCallError> {
) -> Result<Op, FunctionCallError> {
match (message, items) {
(Some(_), Some(_)) => Err(FunctionCallError::RespondToModel(
"Provide either message or items, but not both".to_string(),
@@ -178,7 +179,8 @@ pub(crate) fn parse_collab_input(
Ok(vec![UserInput::Text {
text: message,
text_elements: Vec::new(),
}])
}]
.into())
}
(None, Some(items)) => {
if items.is_empty() {
@@ -186,29 +188,11 @@ pub(crate) fn parse_collab_input(
"Items can't be empty".to_string(),
));
}
Ok(items)
Ok(items.into())
}
}
}
pub(crate) fn input_preview(items: &[UserInput]) -> String {
let parts: Vec<String> = items
.iter()
.map(|item| match item {
UserInput::Text { text, .. } => text.clone(),
UserInput::Image { .. } => "[image]".to_string(),
UserInput::LocalImage { path } => format!("[local_image:{}]", path.display()),
UserInput::Skill { name, path } => {
format!("[skill:${name}]({})", path.display())
}
UserInput::Mention { name, path } => format!("[mention:${name}]({path})"),
_ => "[input]".to_string(),
})
.collect();
parts.join("\n")
}
/// Builds the base config snapshot for a newly spawned sub-agent.
///
/// The returned config starts from the parent's effective config and then refreshes the

View File

@@ -436,6 +436,18 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat
child_snapshot.session_source.get_agent_path().as_deref(),
Some("/root/test_process")
);
assert!(manager.captured_ops().iter().any(|(id, op)| {
*id == child_thread_id
&& matches!(
op,
Op::InterAgentCommunication { communication }
if communication.author == AgentPath::root()
&& communication.recipient.as_str() == "/root/test_process"
&& communication.other_recipients.is_empty()
&& communication.content == "inspect this repo"
&& communication.trigger_turn
)
}));
SendMessageHandlerV2
.handle(invocation(
@@ -490,7 +502,8 @@ async fn multi_agent_v2_send_message_accepts_root_target_from_child() {
vec![UserInput::Text {
text: "inspect this repo".to_string(),
text_elements: Vec::new(),
}],
}]
.into(),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: root.thread_id,
depth: 1,
@@ -654,7 +667,8 @@ async fn multi_agent_v2_list_agents_filters_by_relative_path_prefix() {
vec![UserInput::Text {
text: "research".to_string(),
text_elements: Vec::new(),
}],
}]
.into(),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: root.thread_id,
depth: 1,
@@ -674,7 +688,8 @@ async fn multi_agent_v2_list_agents_filters_by_relative_path_prefix() {
vec![UserInput::Text {
text: "build".to_string(),
text_elements: Vec::new(),
}],
}]
.into(),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: root.thread_id,
depth: 2,

View File

@@ -4,6 +4,7 @@
//! whether the resulting `InterAgentCommunication` should wake the target immediately.
use super::*;
use crate::agent::control::render_input_preview;
use codex_protocol::protocol::InterAgentCommunication;
#[derive(Clone, Copy)]
@@ -83,7 +84,7 @@ fn text_content(
.iter()
.all(|item| matches!(item, UserInput::Text { .. }))
{
return Ok(input_preview(items));
return Ok(render_input_preview(&(items.to_vec().into())));
}
Err(FunctionCallError::RespondToModel(
mode.unsupported_items_error().to_string(),

View File

@@ -1,8 +1,12 @@
use super::*;
use crate::agent::control::SpawnAgentOptions;
use crate::agent::control::render_input_preview;
use crate::agent::next_thread_spawn_depth;
use crate::agent::role::DEFAULT_ROLE_NAME;
use crate::agent::role::apply_role_to_config;
use codex_protocol::AgentPath;
use codex_protocol::protocol::InterAgentCommunication;
use codex_protocol::protocol::Op;
pub(crate) struct Handler;
@@ -33,8 +37,10 @@ impl ToolHandler for Handler {
.as_deref()
.map(str::trim)
.filter(|role| !role.is_empty());
let input_items = parse_collab_input(args.message, args.items)?;
let prompt = input_preview(&input_items);
let initial_operation = parse_collab_input(args.message, args.items)?;
let prompt = render_input_preview(&initial_operation);
let session_source = turn.session_source.clone();
let child_depth = next_thread_spawn_depth(&session_source);
let max_depth = turn.config.agent_max_depth;
@@ -72,19 +78,39 @@ impl ToolHandler for Handler {
apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?;
apply_spawn_agent_overrides(&mut config, child_depth);
let spawn_source = thread_spawn_source(
session.conversation_id,
&turn.session_source,
child_depth,
role_name,
Some(args.task_name.clone()),
)?;
let result = session
.services
.agent_control
.spawn_agent_with_metadata(
config,
input_items,
Some(thread_spawn_source(
session.conversation_id,
&turn.session_source,
child_depth,
role_name,
Some(args.task_name.clone()),
)?),
match (spawn_source.get_agent_path(), initial_operation) {
(Some(recipient), Op::UserInput { items, .. })
if items
.iter()
.all(|item| matches!(item, UserInput::Text { .. })) =>
{
Op::InterAgentCommunication {
communication: InterAgentCommunication::new(
turn.session_source
.get_agent_path()
.unwrap_or_else(AgentPath::root),
recipient,
Vec::new(),
prompt.clone(),
/*trigger_turn*/ true,
),
}
}
(_, initial_operation) => initial_operation,
},
Some(spawn_source),
SpawnAgentOptions {
fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()),
},

View File

@@ -46,9 +46,11 @@ use codex_protocol::openai_models::WebSearchToolType;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_tools::ToolDefinition;
use codex_tools::parse_dynamic_tool;
use codex_tools::parse_mcp_tool;
pub use codex_tools::parse_tool_input_schema;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_template::Template;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
@@ -56,16 +58,27 @@ use serde_json::json;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::LazyLock;
pub type JsonSchema = codex_tools::JsonSchema;
#[cfg(test)]
pub(crate) use codex_tools::mcp_call_tool_result_output_schema;
const TOOL_SEARCH_DESCRIPTION_TEMPLATE: &str =
const TOOL_SEARCH_DESCRIPTION_TEMPLATE_SOURCE: &str =
include_str!("../../templates/search_tool/tool_description.md");
const TOOL_SUGGEST_DESCRIPTION_TEMPLATE: &str =
const TOOL_SEARCH_DESCRIPTION_TEMPLATE_KEY: &str = "app_descriptions";
static TOOL_SEARCH_DESCRIPTION_TEMPLATE: LazyLock<Template> = LazyLock::new(|| {
Template::parse(TOOL_SEARCH_DESCRIPTION_TEMPLATE_SOURCE)
.unwrap_or_else(|err| panic!("tool_search description template must parse: {err}"))
});
const TOOL_SUGGEST_DESCRIPTION_TEMPLATE_SOURCE: &str =
include_str!("../../templates/search_tool/tool_suggest_description.md");
const TOOL_SUGGEST_DESCRIPTION_TEMPLATE_KEY: &str = "discoverable_tools";
static TOOL_SUGGEST_DESCRIPTION_TEMPLATE: LazyLock<Template> = LazyLock::new(|| {
Template::parse(TOOL_SUGGEST_DESCRIPTION_TEMPLATE_SOURCE)
.unwrap_or_else(|err| panic!("tool_suggest description template must parse: {err}"))
});
const WEB_SEARCH_CONTENT_TYPES: [&str; 2] = ["text", "image"];
fn unified_exec_output_schema() -> JsonValue {
@@ -1950,8 +1963,12 @@ fn create_tool_search_tool(app_tools: &HashMap<String, ToolInfo>) -> ToolSpec {
.join("\n")
};
let description =
TOOL_SEARCH_DESCRIPTION_TEMPLATE.replace("{{app_descriptions}}", app_descriptions.as_str());
let description = TOOL_SEARCH_DESCRIPTION_TEMPLATE
.render([(
TOOL_SEARCH_DESCRIPTION_TEMPLATE_KEY,
app_descriptions.as_str(),
)])
.unwrap_or_else(|err| panic!("tool_search description template must render: {err}"));
ToolSpec::ToolSearch {
execution: "client".to_string(),
@@ -2006,10 +2023,13 @@ fn create_tool_suggest_tool(discoverable_tools: &[DiscoverableTool]) -> ToolSpec
},
),
]);
let description = TOOL_SUGGEST_DESCRIPTION_TEMPLATE.replace(
"{{discoverable_tools}}",
format_discoverable_tools(discoverable_tools).as_str(),
);
let discoverable_tools = format_discoverable_tools(discoverable_tools);
let description = TOOL_SUGGEST_DESCRIPTION_TEMPLATE
.render([(
TOOL_SUGGEST_DESCRIPTION_TEMPLATE_KEY,
discoverable_tools.as_str(),
)])
.unwrap_or_else(|err| panic!("tool_suggest description template must render: {err}"));
ToolSpec::Function(ResponsesApiTool {
name: TOOL_SUGGEST_TOOL_NAME.to_string(),
@@ -2366,47 +2386,35 @@ pub(crate) fn mcp_tool_to_openai_tool(
fully_qualified_name: String,
tool: rmcp::model::Tool,
) -> Result<ResponsesApiTool, serde_json::Error> {
let parsed_tool = parse_mcp_tool(&tool)?;
Ok(ResponsesApiTool {
name: fully_qualified_name,
description: parsed_tool.description,
strict: false,
defer_loading: None,
parameters: parsed_tool.input_schema,
output_schema: Some(parsed_tool.output_schema),
})
Ok(tool_definition_to_openai_tool(
parse_mcp_tool(&tool)?.renamed(fully_qualified_name),
))
}
pub(crate) fn mcp_tool_to_deferred_openai_tool(
name: String,
tool: rmcp::model::Tool,
) -> Result<ResponsesApiTool, serde_json::Error> {
let parsed_tool = parse_mcp_tool(&tool)?;
Ok(ResponsesApiTool {
name,
description: parsed_tool.description,
strict: false,
defer_loading: Some(true),
parameters: parsed_tool.input_schema,
output_schema: None,
})
Ok(tool_definition_to_openai_tool(
parse_mcp_tool(&tool)?.renamed(name).into_deferred(),
))
}
fn dynamic_tool_to_openai_tool(
tool: &DynamicToolSpec,
) -> Result<ResponsesApiTool, serde_json::Error> {
let input_schema = parse_tool_input_schema(&tool.input_schema)?;
Ok(tool_definition_to_openai_tool(parse_dynamic_tool(tool)?))
}
Ok(ResponsesApiTool {
name: tool.name.clone(),
description: tool.description.clone(),
fn tool_definition_to_openai_tool(tool_definition: ToolDefinition) -> ResponsesApiTool {
ResponsesApiTool {
name: tool_definition.name,
description: tool_definition.description,
strict: false,
defer_loading: None,
parameters: input_schema,
output_schema: None,
})
defer_loading: tool_definition.defer_loading.then_some(true),
parameters: tool_definition.input_schema,
output_schema: tool_definition.output_schema,
}
}
/// Builds the tool registry builder while collecting tool specs for later serialization.

View File

@@ -8,6 +8,7 @@ use crate::tools::ToolRouter;
use crate::tools::registry::ConfiguredToolSpec;
use crate::tools::router::ToolRouterParams;
use codex_app_server_protocol::AppInfo;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::openai_models::InputModality;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelsResponse;
@@ -126,6 +127,44 @@ fn deferred_responses_api_tool_serializes_with_defer_loading() {
);
}
#[test]
fn dynamic_tool_preserves_defer_loading() {
let tool = DynamicToolSpec {
name: "lookup_order".to_string(),
description: "Look up an order".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"order_id": {"type": "string"}
},
"required": ["order_id"],
"additionalProperties": false,
}),
defer_loading: true,
};
let openai_tool = dynamic_tool_to_openai_tool(&tool).expect("convert dynamic tool");
assert_eq!(
openai_tool,
ResponsesApiTool {
name: "lookup_order".to_string(),
description: "Look up an order".to_string(),
strict: false,
defer_loading: Some(true),
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
"order_id".to_string(),
JsonSchema::String { description: None },
)]),
required: Some(vec!["order_id".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: None,
}
);
}
fn tool_name(tool: &ToolSpec) -> &str {
match tool {
ToolSpec::Function(ResponsesApiTool { name, .. }) => name,
@@ -2242,6 +2281,7 @@ fn tool_suggest_description_lists_discoverable_tools() {
"Apply the stricter explicit-and-unambiguous rule for *discoverable tools* like plugin install suggestions; *missing tools* like connector install suggestions continue to use the normal clear-fit standard."
));
assert!(description.contains("DO NOT explore or recommend tools that are not on this list."));
assert!(!description.contains("{{discoverable_tools}}"));
assert!(!description.contains("tool_search fails to find a good match"));
let JsonSchema::Object { required, .. } = parameters else {
panic!("expected object parameters");

View File

@@ -10,6 +10,7 @@ use codex_features::Features;
use codex_features::FeaturesToml;
use codex_otel::sanitize_metric_tag_value;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_utils_path::env::is_headless_environment;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::path::Path;
@@ -85,7 +86,21 @@ pub fn resolve_windows_sandbox_private_desktop(cfg: &ConfigToml, profile: &Confi
.as_ref()
.and_then(|windows| windows.sandbox_private_desktop)
})
.unwrap_or(true)
.unwrap_or_else(default_windows_sandbox_private_desktop)
}
fn default_windows_sandbox_private_desktop() -> bool {
default_windows_sandbox_private_desktop_for_environment(
cfg!(target_os = "windows"),
is_headless_environment(),
)
}
fn default_windows_sandbox_private_desktop_for_environment(
is_windows: bool,
is_headless: bool,
) -> bool {
!is_windows || !is_headless
}
fn legacy_windows_sandbox_keys_present(features: Option<&FeaturesToml>) -> bool {

View File

@@ -155,9 +155,8 @@ fn resolve_windows_sandbox_private_desktop_prefers_profile_windows() {
#[test]
fn resolve_windows_sandbox_private_desktop_defaults_to_true() {
assert!(resolve_windows_sandbox_private_desktop(
&ConfigToml::default(),
&ConfigProfile::default()
assert!(default_windows_sandbox_private_desktop_for_environment(
/*is_windows*/ true, /*is_headless*/ false
));
}
@@ -176,3 +175,17 @@ fn resolve_windows_sandbox_private_desktop_respects_explicit_cfg_value() {
&ConfigProfile::default()
));
}
#[test]
fn resolve_windows_sandbox_private_desktop_defaults_to_false_for_headless_windows() {
assert!(!default_windows_sandbox_private_desktop_for_environment(
/*is_windows*/ true, /*is_headless*/ true
));
}
#[test]
fn resolve_windows_sandbox_private_desktop_non_windows_default_stays_true() {
assert!(default_windows_sandbox_private_desktop_for_environment(
/*is_windows*/ false, /*is_headless*/ true
));
}

View File

@@ -2,7 +2,6 @@
<context>User initiated a review task. Here's the full review output from reviewer model. User may select one or more comments to resolve.</context>
<action>review</action>
<results>
{results}
{{results}}
</results>
</user_action>

View File

@@ -179,7 +179,7 @@ impl ActionKind {
Ok((event, Some(command)))
}
ActionKind::RunCommand { command } => {
let event = shell_event(call_id, command, 1_000, sandbox_permissions)?;
let event = shell_event(call_id, command, 2_000, sandbox_permissions)?;
Ok((event, Some(command.to_string())))
}
ActionKind::RunUnifiedExecCommand {

View File

@@ -512,6 +512,15 @@ pub enum Op {
ListModels,
}
impl From<Vec<UserInput>> for Op {
fn from(value: Vec<UserInput>) -> Self {
Op::UserInput {
items: value,
final_output_json_schema: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)]
pub struct InterAgentCommunication {
pub author: AgentPath,

View File

@@ -20,7 +20,7 @@ fn system_bwrap_warning_for_lookup(system_bwrap_path: Option<PathBuf>) -> Option
pub fn find_system_bwrap_in_path() -> Option<PathBuf> {
let search_path = std::env::var_os("PATH")?;
let cwd = std::env::current_dir().ok()?;
find_system_bwrap_in_search_paths(std::iter::once(PathBuf::from(search_path)), &cwd)
find_system_bwrap_in_search_paths(std::env::split_paths(&search_path), &cwd)
}
fn find_system_bwrap_in_search_paths(

View File

@@ -33,7 +33,7 @@ exit 1
}
#[test]
fn finds_first_executable_bwrap_in_search_paths() {
fn finds_first_executable_bwrap_in_joined_search_path() {
let temp_dir = tempdir().expect("temp dir");
let cwd = temp_dir.path().join("cwd");
let first_dir = temp_dir.path().join("first");
@@ -43,15 +43,16 @@ fn finds_first_executable_bwrap_in_search_paths() {
std::fs::create_dir_all(&second_dir).expect("create second dir");
std::fs::write(first_dir.join("bwrap"), "not executable").expect("write non-executable bwrap");
let expected_bwrap = write_named_fake_bwrap_in(&second_dir);
let search_path = std::env::join_paths([first_dir, second_dir]).expect("join search path");
assert_eq!(
find_system_bwrap_in_search_paths(vec![first_dir, second_dir], &cwd),
find_system_bwrap_in_search_paths(std::env::split_paths(&search_path), &cwd),
Some(expected_bwrap)
);
}
#[test]
fn skips_workspace_local_bwrap_in_search_paths() {
fn skips_workspace_local_bwrap_in_joined_search_path() {
let temp_dir = tempdir().expect("temp dir");
let cwd = temp_dir.path().join("cwd");
let trusted_dir = temp_dir.path().join("trusted");
@@ -59,9 +60,10 @@ fn skips_workspace_local_bwrap_in_search_paths() {
std::fs::create_dir_all(&trusted_dir).expect("create trusted dir");
let _workspace_bwrap = write_named_fake_bwrap_in(&cwd);
let expected_bwrap = write_named_fake_bwrap_in(&trusted_dir);
let search_path = std::env::join_paths([cwd.clone(), trusted_dir]).expect("join search path");
assert_eq!(
find_system_bwrap_in_search_paths(vec![cwd.clone(), trusted_dir], &cwd),
find_system_bwrap_in_search_paths(std::env::split_paths(&search_path), &cwd),
Some(expected_bwrap)
);
}

View File

@@ -60,8 +60,12 @@ fn extract_fds(control: &[u8]) -> Vec<OwnedFd> {
if level == libc::SOL_SOCKET && ty == libc::SCM_RIGHTS {
let data_ptr = unsafe { libc::CMSG_DATA(cmsg).cast::<RawFd>() };
let fd_count: usize = {
let cmsg_data_len =
unsafe { (*cmsg).cmsg_len as usize } - unsafe { libc::CMSG_LEN(0) as usize };
// `cmsghdr::cmsg_len` is not typed consistently across targets, so normalize it
// before doing the size arithmetic.
#[allow(clippy::useless_conversion)]
let cmsg_data_len = usize::try_from(unsafe { (*cmsg).cmsg_len })
.expect("cmsghdr length fits")
- unsafe { libc::CMSG_LEN(0) as usize };
cmsg_data_len / size_of::<RawFd>()
};
for i in 0..fd_count {

View File

@@ -8,6 +8,7 @@ version.workspace = true
workspace = true
[dependencies]
codex-protocol = { workspace = true }
rmcp = { workspace = true, default-features = false, features = [
"base64",
"macros",

View File

@@ -9,8 +9,9 @@ schema primitives that no longer need to live in `core/src/tools/spec.rs`:
- `JsonSchema`
- `AdditionalProperties`
- `ToolDefinition`
- `parse_tool_input_schema()`
- `ParsedMcpTool`
- `parse_dynamic_tool()`
- `parse_mcp_tool()`
- `mcp_call_tool_result_output_schema()`

View File

@@ -0,0 +1,23 @@
use crate::ToolDefinition;
use crate::parse_tool_input_schema;
use codex_protocol::dynamic_tools::DynamicToolSpec;
pub fn parse_dynamic_tool(tool: &DynamicToolSpec) -> Result<ToolDefinition, serde_json::Error> {
let DynamicToolSpec {
name,
description,
input_schema,
defer_loading,
} = tool;
Ok(ToolDefinition {
name: name.clone(),
description: description.clone(),
input_schema: parse_tool_input_schema(input_schema)?,
output_schema: None,
defer_loading: *defer_loading,
})
}
#[cfg(test)]
#[path = "dynamic_tool_tests.rs"]
mod tests;

View File

@@ -0,0 +1,70 @@
use super::parse_dynamic_tool;
use crate::JsonSchema;
use crate::ToolDefinition;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
#[test]
fn parse_dynamic_tool_sanitizes_input_schema() {
let tool = DynamicToolSpec {
name: "lookup_ticket".to_string(),
description: "Fetch a ticket".to_string(),
input_schema: serde_json::json!({
"properties": {
"id": {
"description": "Ticket identifier"
}
}
}),
defer_loading: false,
};
assert_eq!(
parse_dynamic_tool(&tool).expect("parse dynamic tool"),
ToolDefinition {
name: "lookup_ticket".to_string(),
description: "Fetch a ticket".to_string(),
input_schema: JsonSchema::Object {
properties: BTreeMap::from([(
"id".to_string(),
JsonSchema::String {
description: Some("Ticket identifier".to_string()),
},
)]),
required: None,
additional_properties: None,
},
output_schema: None,
defer_loading: false,
}
);
}
#[test]
fn parse_dynamic_tool_preserves_defer_loading() {
let tool = DynamicToolSpec {
name: "lookup_ticket".to_string(),
description: "Fetch a ticket".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {}
}),
defer_loading: true,
};
assert_eq!(
parse_dynamic_tool(&tool).expect("parse dynamic tool"),
ToolDefinition {
name: "lookup_ticket".to_string(),
description: "Fetch a ticket".to_string(),
input_schema: JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: None,
},
output_schema: None,
defer_loading: true,
}
);
}

View File

@@ -1,11 +1,14 @@
//! Shared tool-schema parsing primitives that can live outside `codex-core`.
mod dynamic_tool;
mod json_schema;
mod mcp_tool;
mod tool_definition;
pub use dynamic_tool::parse_dynamic_tool;
pub use json_schema::AdditionalProperties;
pub use json_schema::JsonSchema;
pub use json_schema::parse_tool_input_schema;
pub use mcp_tool::ParsedMcpTool;
pub use mcp_tool::mcp_call_tool_result_output_schema;
pub use mcp_tool::parse_mcp_tool;
pub use tool_definition::ToolDefinition;

View File

@@ -1,18 +1,9 @@
use crate::JsonSchema;
use crate::ToolDefinition;
use crate::parse_tool_input_schema;
use serde_json::Value as JsonValue;
use serde_json::json;
/// Parsed MCP tool metadata and schemas that can be adapted into a higher-level
/// tool spec by downstream crates.
#[derive(Debug, PartialEq)]
pub struct ParsedMcpTool {
pub description: String,
pub input_schema: JsonSchema,
pub output_schema: JsonValue,
}
pub fn parse_mcp_tool(tool: &rmcp::model::Tool) -> Result<ParsedMcpTool, serde_json::Error> {
pub fn parse_mcp_tool(tool: &rmcp::model::Tool) -> Result<ToolDefinition, serde_json::Error> {
let mut serialized_input_schema = serde_json::Value::Object(tool.input_schema.as_ref().clone());
// OpenAI models mandate the "properties" field in the schema. Some MCP
@@ -34,10 +25,14 @@ pub fn parse_mcp_tool(tool: &rmcp::model::Tool) -> Result<ParsedMcpTool, serde_j
.map(|output_schema| serde_json::Value::Object(output_schema.as_ref().clone()))
.unwrap_or_else(|| JsonValue::Object(serde_json::Map::new()));
Ok(ParsedMcpTool {
Ok(ToolDefinition {
name: tool.name.to_string(),
description: tool.description.clone().map(Into::into).unwrap_or_default(),
input_schema,
output_schema: mcp_call_tool_result_output_schema(structured_content_schema),
output_schema: Some(mcp_call_tool_result_output_schema(
structured_content_schema,
)),
defer_loading: false,
})
}

View File

@@ -1,7 +1,7 @@
use super::ParsedMcpTool;
use super::mcp_call_tool_result_output_schema;
use super::parse_mcp_tool;
use crate::JsonSchema;
use crate::ToolDefinition;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
@@ -31,14 +31,16 @@ fn parse_mcp_tool_inserts_empty_properties() {
assert_eq!(
parse_mcp_tool(&tool).expect("parse MCP tool"),
ParsedMcpTool {
ToolDefinition {
name: "no_props".to_string(),
description: "No properties".to_string(),
input_schema: JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: None,
},
output_schema: mcp_call_tool_result_output_schema(serde_json::json!({})),
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
defer_loading: false,
}
);
}
@@ -67,14 +69,15 @@ fn parse_mcp_tool_preserves_top_level_output_schema() {
assert_eq!(
parse_mcp_tool(&tool).expect("parse MCP tool"),
ParsedMcpTool {
ToolDefinition {
name: "with_output".to_string(),
description: "Has output schema".to_string(),
input_schema: JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: None,
},
output_schema: mcp_call_tool_result_output_schema(serde_json::json!({
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({
"properties": {
"result": {
"properties": {
@@ -83,7 +86,8 @@ fn parse_mcp_tool_preserves_top_level_output_schema() {
}
},
"required": ["result"]
})),
}))),
defer_loading: false,
}
);
}
@@ -105,16 +109,18 @@ fn parse_mcp_tool_preserves_output_schema_without_inferred_type() {
assert_eq!(
parse_mcp_tool(&tool).expect("parse MCP tool"),
ParsedMcpTool {
ToolDefinition {
name: "with_enum_output".to_string(),
description: "Has enum output schema".to_string(),
input_schema: JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: None,
},
output_schema: mcp_call_tool_result_output_schema(serde_json::json!({
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({
"enum": ["ok", "error"]
})),
}))),
defer_loading: false,
}
);
}

View File

@@ -0,0 +1,30 @@
use crate::JsonSchema;
use serde_json::Value as JsonValue;
/// Tool metadata and schemas that downstream crates can adapt into higher-level
/// tool specs.
#[derive(Debug, PartialEq)]
pub struct ToolDefinition {
pub name: String,
pub description: String,
pub input_schema: JsonSchema,
pub output_schema: Option<JsonValue>,
pub defer_loading: bool,
}
impl ToolDefinition {
pub fn renamed(mut self, name: String) -> Self {
self.name = name;
self
}
pub fn into_deferred(mut self) -> Self {
self.output_schema = None;
self.defer_loading = true;
self
}
}
#[cfg(test)]
#[path = "tool_definition_tests.rs"]
mod tests;

View File

@@ -0,0 +1,43 @@
use super::ToolDefinition;
use crate::JsonSchema;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
fn tool_definition() -> ToolDefinition {
ToolDefinition {
name: "lookup_order".to_string(),
description: "Look up an order".to_string(),
input_schema: JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: None,
},
output_schema: Some(serde_json::json!({
"type": "object",
})),
defer_loading: false,
}
}
#[test]
fn renamed_overrides_name_only() {
assert_eq!(
tool_definition().renamed("mcp__orders__lookup_order".to_string()),
ToolDefinition {
name: "mcp__orders__lookup_order".to_string(),
..tool_definition()
}
);
}
#[test]
fn into_deferred_drops_output_schema_and_sets_defer_loading() {
assert_eq!(
tool_definition().into_deferred(),
ToolDefinition {
output_schema: None,
defer_loading: true,
..tool_definition()
}
);
}

View File

@@ -480,6 +480,7 @@ pub(crate) enum AppEvent {
/// Voice transcription finished for the given placeholder id.
#[cfg(not(target_os = "linux"))]
#[cfg_attr(not(feature = "voice-input"), allow(dead_code))]
TranscriptionComplete {
id: String,
text: String,

View File

@@ -167,6 +167,7 @@ mod voice {
pub(crate) enum RealtimeInputBehavior {
Ungated,
PlaybackAware {
#[allow(dead_code)]
playback_queued_samples: Arc<AtomicUsize>,
},
}

View File

@@ -491,6 +491,7 @@ pub(crate) enum AppEvent {
/// Voice transcription finished for the given placeholder id.
#[cfg(not(target_os = "linux"))]
#[cfg_attr(not(feature = "voice-input"), allow(dead_code))]
TranscriptionComplete {
id: String,
text: String,

View File

@@ -69,6 +69,9 @@ bazel-lock-check:
bazel-test:
bazel test //... --keep_going
bazel-clippy:
bazel build --config=clippy -- //codex-rs/... -//codex-rs/v8-poc:all
bazel-remote-test:
bazel test //... --config=remote --platforms=//:rbe --keep_going

View File

@@ -44,33 +44,43 @@ export function createTestClient(options: CreateTestClientOptions = {}): TestCli
codexPathOverride: codexExecPath,
baseUrl: options.baseUrl,
apiKey: options.apiKey,
config: mergeTestProviderConfig(options.baseUrl, options.config),
config: mergeTestConfig(options.baseUrl, options.config),
env,
}),
};
}
function mergeTestProviderConfig(
function mergeTestConfig(
baseUrl: string | undefined,
config: CodexConfigObject | undefined,
): CodexConfigObject | undefined {
if (!baseUrl || hasExplicitProviderConfig(config)) {
return config;
}
const mergedConfig: CodexConfigObject | undefined =
!baseUrl || hasExplicitProviderConfig(config)
? config
: {
...config,
// Built-in providers are merged before user config, so tests need a
// custom provider entry to force SSE against the local mock server.
model_provider: "mock",
model_providers: {
mock: {
name: "Mock provider for test",
base_url: baseUrl,
wire_api: "responses",
supports_websockets: false,
},
},
};
const featureOverrides = mergedConfig?.features;
// Built-in providers are merged before user config, so tests need a custom
// provider entry to force SSE against the local mock server.
return {
...config,
model_provider: "mock",
model_providers: {
mock: {
name: "Mock provider for test",
base_url: baseUrl,
wire_api: "responses",
supports_websockets: false,
},
},
...mergedConfig,
// Disable plugins in SDK integration tests so background curated-plugin
// sync does not race temp CODEX_HOME cleanup.
features:
featureOverrides && typeof featureOverrides === "object" && !Array.isArray(featureOverrides)
? { ...featureOverrides, plugins: false }
: { plugins: false },
};
}