mirror of
https://github.com/openai/codex.git
synced 2026-05-15 00:32:51 +00:00
Compare commits
10 Commits
jif/fix-on
...
codex/issu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a3b2d1b65 | ||
|
|
95845cf6ce | ||
|
|
15fbf9d4f5 | ||
|
|
caee620a53 | ||
|
|
2616c7cf12 | ||
|
|
617475e54b | ||
|
|
ec089fd22a | ||
|
|
426f28ca99 | ||
|
|
2b71717ccf | ||
|
|
f044ca64df |
5
.bazelrc
5
.bazelrc
@@ -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
|
||||
|
||||
61
.github/actions/setup-bazel-ci/action.yml
vendored
Normal file
61
.github/actions/setup-bazel-ci/action.yml
vendored
Normal 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
178
.github/scripts/run-bazel-ci.sh
vendored
Executable 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
|
||||
228
.github/workflows/bazel.yml
vendored
228
.github/workflows/bazel.yml
vendored
@@ -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:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
exports_files([
|
||||
"clippy.toml",
|
||||
"node-version.txt",
|
||||
])
|
||||
|
||||
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
@@ -2614,6 +2614,7 @@ dependencies = [
|
||||
name = "codex-tools"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"codex-protocol",
|
||||
"pretty_assertions",
|
||||
"rmcp",
|
||||
"serde",
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
)))),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()),
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -8,6 +8,7 @@ version.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
codex-protocol = { workspace = true }
|
||||
rmcp = { workspace = true, default-features = false, features = [
|
||||
"base64",
|
||||
"macros",
|
||||
|
||||
@@ -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()`
|
||||
|
||||
|
||||
23
codex-rs/tools/src/dynamic_tool.rs
Normal file
23
codex-rs/tools/src/dynamic_tool.rs
Normal 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;
|
||||
70
codex-rs/tools/src/dynamic_tool_tests.rs
Normal file
70
codex-rs/tools/src/dynamic_tool_tests.rs
Normal 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,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
30
codex-rs/tools/src/tool_definition.rs
Normal file
30
codex-rs/tools/src/tool_definition.rs
Normal 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;
|
||||
43
codex-rs/tools/src/tool_definition_tests.rs
Normal file
43
codex-rs/tools/src/tool_definition_tests.rs
Normal 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()
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -167,6 +167,7 @@ mod voice {
|
||||
pub(crate) enum RealtimeInputBehavior {
|
||||
Ungated,
|
||||
PlaybackAware {
|
||||
#[allow(dead_code)]
|
||||
playback_queued_samples: Arc<AtomicUsize>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
3
justfile
3
justfile
@@ -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
|
||||
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user