diff --git a/.codex/skills/remote-tests/SKILL.md b/.codex/skills/remote-tests/SKILL.md new file mode 100644 index 0000000000..ee35fc2b21 --- /dev/null +++ b/.codex/skills/remote-tests/SKILL.md @@ -0,0 +1,16 @@ +--- +name: remote-tests +description: How to run tests using remote executor. +--- + +Some codex integration tests support a running against a remote executor. +This means that when CODEX_TEST_REMOTE_ENV environment variable is set they will attempt to start an executor process in a docker container CODEX_TEST_REMOTE_ENV points to and use it in tests. + +Docker container is built and initialized via ./scripts/test-remote-env.sh + +Currently running remote tests is only supported on Linux, so you need to use a devbox to run them + +You can list devboxes via `applied_devbox ls`, pick the one with `codex` in the name. +Connect to devbox via `ssh `. +Reuse the same checkout of codex in `~/code/codex`. Reset files if needed. Multiple checkouts take longer to build and take up more space. +Check whether the SHA and modified files are in sync between remote and local. diff --git a/.github/actions/macos-code-sign/action.yml b/.github/actions/macos-code-sign/action.yml index ea4a19a8f5..200b23901f 100644 --- a/.github/actions/macos-code-sign/action.yml +++ b/.github/actions/macos-code-sign/action.yml @@ -132,9 +132,11 @@ runs: keychain_args+=(--keychain "${APPLE_CODESIGN_KEYCHAIN}") fi + entitlements_path="$GITHUB_ACTION_PATH/codex.entitlements.plist" + for binary in codex codex-responses-api-proxy; do path="codex-rs/target/${TARGET}/release/${binary}" - codesign --force --options runtime --timestamp --sign "$APPLE_CODESIGN_IDENTITY" "${keychain_args[@]}" "$path" + codesign --force --options runtime --timestamp --entitlements "$entitlements_path" --sign "$APPLE_CODESIGN_IDENTITY" "${keychain_args[@]}" "$path" done - name: Notarize macOS binaries diff --git a/.github/actions/macos-code-sign/codex.entitlements.plist b/.github/actions/macos-code-sign/codex.entitlements.plist new file mode 100644 index 0000000000..d35e43ae58 --- /dev/null +++ b/.github/actions/macos-code-sign/codex.entitlements.plist @@ -0,0 +1,8 @@ + + + + + com.apple.security.cs.allow-jit + + + diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 526ceeb1ae..9db72b1d21 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -447,6 +447,24 @@ jobs: echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" + - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl' }} + name: Configure musl rusty_v8 artifact overrides + env: + TARGET: ${{ matrix.target }} + shell: bash + run: | + set -euo pipefail + version="$(python3 "${GITHUB_WORKSPACE}/.github/scripts/rusty_v8_bazel.py" resolved-v8-crate-version)" + release_tag="rusty-v8-v${version}" + base_url="https://github.com/openai/codex/releases/download/${release_tag}" + archive="https://github.com/openai/codex/releases/download/rusty-v8-v${version}/librusty_v8_release_${TARGET}.a.gz" + binding_dir="${RUNNER_TEMP}/rusty_v8" + binding_path="${binding_dir}/src_binding_release_${TARGET}.rs" + mkdir -p "${binding_dir}" + curl -fsSL "${base_url}/src_binding_release_${TARGET}.rs" -o "${binding_path}" + echo "RUSTY_V8_ARCHIVE=${archive}" >> "$GITHUB_ENV" + echo "RUSTY_V8_SRC_BINDING_PATH=${binding_path}" >> "$GITHUB_ENV" + - name: Install cargo-chef if: ${{ matrix.profile == 'release' }} uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 @@ -527,7 +545,7 @@ jobs: key: apt-${{ matrix.runner }}-${{ matrix.target }}-v1 tests: - name: Tests — ${{ matrix.runner }} - ${{ matrix.target }} + name: Tests — ${{ matrix.runner }} - ${{ matrix.target }}${{ matrix.remote_env == 'true' && ' (remote)' || '' }} runs-on: ${{ matrix.runs_on || matrix.runner }} timeout-minutes: 30 needs: changed @@ -553,6 +571,7 @@ jobs: - runner: ubuntu-24.04 target: x86_64-unknown-linux-gnu profile: dev + remote_env: "true" runs_on: group: codex-runners labels: codex-linux-x64 @@ -590,6 +609,7 @@ jobs: sudo apt-get update -y sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev fi + # Some integration tests rely on DotSlash being installed. # See https://github.com/openai/codex/pull/7617. - name: Install DotSlash @@ -674,6 +694,15 @@ jobs: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 fi + - name: Set up remote test env (Docker) + if: ${{ runner.os == 'Linux' && matrix.remote_env == 'true' }} + shell: bash + run: | + set -euo pipefail + export CODEX_TEST_REMOTE_ENV_CONTAINER_NAME=codex-remote-test-env + source "${GITHUB_WORKSPACE}/scripts/test-remote-env.sh" + echo "CODEX_TEST_REMOTE_ENV=${CODEX_TEST_REMOTE_ENV}" >> "$GITHUB_ENV" + - name: tests id: test run: cargo nextest run --all-features --no-fail-fast --target ${{ matrix.target }} --cargo-profile ci-test --timings @@ -726,6 +755,16 @@ jobs: echo '```'; } >> "$GITHUB_STEP_SUMMARY" + - name: Tear down remote test env + if: ${{ always() && runner.os == 'Linux' && matrix.remote_env == 'true' }} + shell: bash + run: | + set +e + if [[ "${{ steps.test.outcome }}" != "success" ]]; then + docker logs codex-remote-test-env || true + fi + docker rm -f codex-remote-test-env >/dev/null 2>&1 || true + - name: verify tests passed if: steps.test.outcome == 'failure' run: | diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 35078cf33d..4a3b35bd1c 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -210,6 +210,24 @@ jobs: echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" + - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl' }} + name: Configure musl rusty_v8 artifact overrides + env: + TARGET: ${{ matrix.target }} + shell: bash + run: | + set -euo pipefail + version="$(python3 "${GITHUB_WORKSPACE}/.github/scripts/rusty_v8_bazel.py" resolved-v8-crate-version)" + release_tag="rusty-v8-v${version}" + base_url="https://github.com/openai/codex/releases/download/${release_tag}" + archive="https://github.com/openai/codex/releases/download/rusty-v8-v${version}/librusty_v8_release_${TARGET}.a.gz" + binding_dir="${RUNNER_TEMP}/rusty_v8" + binding_path="${binding_dir}/src_binding_release_${TARGET}.rs" + mkdir -p "${binding_dir}" + curl -fsSL "${base_url}/src_binding_release_${TARGET}.rs" -o "${binding_path}" + echo "RUSTY_V8_ARCHIVE=${archive}" >> "$GITHUB_ENV" + echo "RUSTY_V8_SRC_BINDING_PATH=${binding_path}" >> "$GITHUB_ENV" + - name: Cargo build shell: bash run: | diff --git a/MODULE.bazel b/MODULE.bazel index f6f0fd0906..8564db701a 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -2,31 +2,41 @@ module(name = "codex") bazel_dep(name = "bazel_skylib", version = "1.8.2") bazel_dep(name = "platforms", version = "1.0.0") -bazel_dep(name = "llvm", version = "0.6.7") +bazel_dep(name = "llvm", version = "0.6.8") register_toolchains("@llvm//toolchain:all") osx = use_extension("@llvm//extensions:osx.bzl", "osx") -osx.framework(name = "ApplicationServices") -osx.framework(name = "AppKit") -osx.framework(name = "ColorSync") -osx.framework(name = "CoreFoundation") -osx.framework(name = "CoreGraphics") -osx.framework(name = "CoreServices") -osx.framework(name = "CoreText") -osx.framework(name = "AudioToolbox") -osx.framework(name = "CFNetwork") -osx.framework(name = "FontServices") -osx.framework(name = "AudioUnit") -osx.framework(name = "CoreAudio") -osx.framework(name = "CoreAudioTypes") -osx.framework(name = "Foundation") -osx.framework(name = "ImageIO") -osx.framework(name = "IOKit") -osx.framework(name = "Kernel") -osx.framework(name = "OSLog") -osx.framework(name = "Security") -osx.framework(name = "SystemConfiguration") +osx.from_archive( + sha256 = "6a4922f89487a96d7054ec6ca5065bfddd9f1d017c74d82f1d79cecf7feb8228", + strip_prefix = "Payload/Library/Developer/CommandLineTools/SDKs/MacOSX26.2.sdk", + type = "pkg", + urls = [ + "https://swcdn.apple.com/content/downloads/26/44/047-81934-A_28TPKM5SD1/ps6pk6dk4x02vgfa5qsctq6tgf23t5f0w2/CLTools_macOSNMOS_SDK.pkg", + ], +) +osx.frameworks(names = [ + "ApplicationServices", + "AppKit", + "ColorSync", + "CoreFoundation", + "CoreGraphics", + "CoreServices", + "CoreText", + "AudioToolbox", + "CFNetwork", + "FontServices", + "AudioUnit", + "CoreAudio", + "CoreAudioTypes", + "Foundation", + "ImageIO", + "IOKit", + "Kernel", + "OSLog", + "Security", + "SystemConfiguration", +]) use_repo(osx, "macos_sdk") # Needed to disable xcode... @@ -134,6 +144,33 @@ crate.annotation( ) http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") +http_file = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_file") +new_local_repository = use_repo_rule("@bazel_tools//tools/build_defs/repo:local.bzl", "new_local_repository") + +new_local_repository( + name = "v8_targets", + build_file = "//third_party/v8:BUILD.bazel", + path = "third_party/v8", +) + +crate.annotation( + build_script_data = [ + "@v8_targets//:rusty_v8_archive_for_target", + "@v8_targets//:rusty_v8_binding_for_target", + ], + build_script_env = { + "RUSTY_V8_ARCHIVE": "$(execpath @v8_targets//:rusty_v8_archive_for_target)", + "RUSTY_V8_SRC_BINDING_PATH": "$(execpath @v8_targets//:rusty_v8_binding_for_target)", + }, + crate = "v8", + gen_build_script = "on", + patch_args = ["-p1"], + patches = [ + "//patches:rusty_v8_prebuilt_out_dir.patch", + ], +) + +inject_repo(crate, "v8_targets") llvm = use_extension("@llvm//extensions:llvm.bzl", "llvm") use_repo(llvm, "llvm-project") @@ -200,6 +237,86 @@ http_archive( urls = ["https://static.crates.io/crates/v8/v8-146.4.0.crate"], ) +http_file( + name = "rusty_v8_146_4_0_aarch64_apple_darwin_archive", + downloaded_file_path = "librusty_v8_release_aarch64-apple-darwin.a.gz", + urls = [ + "https://github.com/denoland/rusty_v8/releases/download/v146.4.0/librusty_v8_release_aarch64-apple-darwin.a.gz", + ], +) + +http_file( + name = "rusty_v8_146_4_0_aarch64_unknown_linux_gnu_archive", + downloaded_file_path = "librusty_v8_release_aarch64-unknown-linux-gnu.a.gz", + urls = [ + "https://github.com/denoland/rusty_v8/releases/download/v146.4.0/librusty_v8_release_aarch64-unknown-linux-gnu.a.gz", + ], +) + +http_file( + name = "rusty_v8_146_4_0_aarch64_pc_windows_msvc_archive", + downloaded_file_path = "rusty_v8_release_aarch64-pc-windows-msvc.lib.gz", + urls = [ + "https://github.com/denoland/rusty_v8/releases/download/v146.4.0/rusty_v8_release_aarch64-pc-windows-msvc.lib.gz", + ], +) + +http_file( + name = "rusty_v8_146_4_0_x86_64_apple_darwin_archive", + downloaded_file_path = "librusty_v8_release_x86_64-apple-darwin.a.gz", + urls = [ + "https://github.com/denoland/rusty_v8/releases/download/v146.4.0/librusty_v8_release_x86_64-apple-darwin.a.gz", + ], +) + +http_file( + name = "rusty_v8_146_4_0_x86_64_unknown_linux_gnu_archive", + downloaded_file_path = "librusty_v8_release_x86_64-unknown-linux-gnu.a.gz", + urls = [ + "https://github.com/denoland/rusty_v8/releases/download/v146.4.0/librusty_v8_release_x86_64-unknown-linux-gnu.a.gz", + ], +) + +http_file( + name = "rusty_v8_146_4_0_x86_64_pc_windows_msvc_archive", + downloaded_file_path = "rusty_v8_release_x86_64-pc-windows-msvc.lib.gz", + urls = [ + "https://github.com/denoland/rusty_v8/releases/download/v146.4.0/rusty_v8_release_x86_64-pc-windows-msvc.lib.gz", + ], +) + +http_file( + name = "rusty_v8_146_4_0_aarch64_unknown_linux_musl_archive", + downloaded_file_path = "librusty_v8_release_aarch64-unknown-linux-musl.a.gz", + urls = [ + "https://github.com/openai/codex/releases/download/rusty-v8-v146.4.0/librusty_v8_release_aarch64-unknown-linux-musl.a.gz", + ], +) + +http_file( + name = "rusty_v8_146_4_0_aarch64_unknown_linux_musl_binding", + downloaded_file_path = "src_binding_release_aarch64-unknown-linux-musl.rs", + urls = [ + "https://github.com/openai/codex/releases/download/rusty-v8-v146.4.0/src_binding_release_aarch64-unknown-linux-musl.rs", + ], +) + +http_file( + name = "rusty_v8_146_4_0_x86_64_unknown_linux_musl_archive", + downloaded_file_path = "librusty_v8_release_x86_64-unknown-linux-musl.a.gz", + urls = [ + "https://github.com/openai/codex/releases/download/rusty-v8-v146.4.0/librusty_v8_release_x86_64-unknown-linux-musl.a.gz", + ], +) + +http_file( + name = "rusty_v8_146_4_0_x86_64_unknown_linux_musl_binding", + downloaded_file_path = "src_binding_release_x86_64-unknown-linux-musl.rs", + urls = [ + "https://github.com/openai/codex/releases/download/rusty-v8-v146.4.0/src_binding_release_x86_64-unknown-linux-musl.rs", + ], +) + use_repo(crate, "crates") bazel_dep(name = "libcap", version = "2.27.bcr.1") diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 2ee57d7426..e0d384767b 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -86,7 +86,8 @@ "https://bcr.bazel.build/modules/libcap/2.27.bcr.1/source.json": "3b116cbdbd25a68ffb587b672205f6d353a4c19a35452e480d58fc89531e0a10", "https://bcr.bazel.build/modules/libpfm/4.11.0/MODULE.bazel": "45061ff025b301940f1e30d2c16bea596c25b176c8b6b3087e92615adbd52902", "https://bcr.bazel.build/modules/llvm/0.6.7/MODULE.bazel": "d37a2e10571864dc6a5bb53c29216d90b9400bbcadb422337f49107fd2eaf0d2", - "https://bcr.bazel.build/modules/llvm/0.6.7/source.json": "c40bcce08d2adbd658aae609976ce4ae4fdc44f3299fffa29c7fa9bf7e7d6d2b", + "https://bcr.bazel.build/modules/llvm/0.6.8/MODULE.bazel": "53468e4a4be409c2d34e5b7331d2e1fef982151b777655ca3c0047225b333629", + "https://bcr.bazel.build/modules/llvm/0.6.8/source.json": "b673af466f716e01d6243f59e47729e99f37dc5e17026d2bf18c98206f09b6c5", "https://bcr.bazel.build/modules/nlohmann_json/3.6.1/MODULE.bazel": "6f7b417dcc794d9add9e556673ad25cb3ba835224290f4f848f8e2db1e1fca74", "https://bcr.bazel.build/modules/nlohmann_json/3.6.1/source.json": "f448c6e8963fdfa7eb831457df83ad63d3d6355018f6574fb017e8169deb43a9", "https://bcr.bazel.build/modules/openssl/3.5.4.bcr.0/MODULE.bazel": "0f6b8f20b192b9ff0781406256150bcd46f19e66d807dcb0c540548439d6fc35", @@ -644,8 +645,8 @@ "atoi_2.0.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2.14\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"num-traits/std\"]}}", "atomic-waker_1.1.2": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"cargo_bench_support\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.5\"},{\"default_features\":false,\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.7.0\"}],\"features\":{}}", "autocfg_1.5.0": "{\"dependencies\":[],\"features\":{}}", - "aws-lc-rs_1.15.4": "{\"dependencies\":[{\"name\":\"aws-lc-fips-sys\",\"optional\":true,\"req\":\"^0.13.1\"},{\"default_features\":false,\"name\":\"aws-lc-sys\",\"optional\":true,\"req\":\"^0.37.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"=4.4.18\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1.0.15\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.11.1\"},{\"name\":\"untrusted\",\"optional\":true,\"req\":\"^0.7.1\"},{\"name\":\"zeroize\",\"req\":\"^1.8.1\"}],\"features\":{\"alloc\":[],\"asan\":[\"aws-lc-sys?/asan\",\"aws-lc-fips-sys?/asan\"],\"bindgen\":[\"aws-lc-sys?/bindgen\",\"aws-lc-fips-sys?/bindgen\"],\"default\":[\"aws-lc-sys\",\"alloc\",\"ring-io\",\"ring-sig-verify\"],\"fips\":[\"dep:aws-lc-fips-sys\"],\"non-fips\":[\"aws-lc-sys\"],\"prebuilt-nasm\":[\"aws-lc-sys?/prebuilt-nasm\"],\"ring-io\":[\"dep:untrusted\"],\"ring-sig-verify\":[\"dep:untrusted\"],\"test_logging\":[],\"unstable\":[]}}", - "aws-lc-sys_0.37.0": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.72.0\"},{\"features\":[\"parallel\"],\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.2.26\"},{\"kind\":\"build\",\"name\":\"cmake\",\"req\":\"^0.1.54\"},{\"kind\":\"build\",\"name\":\"dunce\",\"req\":\"^1.0.5\"},{\"kind\":\"build\",\"name\":\"fs_extra\",\"req\":\"^1.3.0\"}],\"features\":{\"all-bindings\":[],\"asan\":[],\"bindgen\":[\"dep:bindgen\"],\"default\":[\"all-bindings\"],\"disable-prebuilt-nasm\":[],\"prebuilt-nasm\":[],\"ssl\":[\"bindgen\",\"all-bindings\"]}}", + "aws-lc-rs_1.16.2": "{\"dependencies\":[{\"name\":\"aws-lc-fips-sys\",\"optional\":true,\"req\":\"^0.13.1\"},{\"default_features\":false,\"name\":\"aws-lc-sys\",\"optional\":true,\"req\":\"^0.39.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.4\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1.0.15\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.11.1\"},{\"name\":\"untrusted\",\"optional\":true,\"req\":\"^0.7.1\"},{\"name\":\"zeroize\",\"req\":\"^1.8.1\"}],\"features\":{\"alloc\":[],\"asan\":[\"aws-lc-sys?/asan\",\"aws-lc-fips-sys?/asan\"],\"bindgen\":[\"aws-lc-sys?/bindgen\",\"aws-lc-fips-sys?/bindgen\"],\"default\":[\"aws-lc-sys\",\"alloc\",\"ring-io\",\"ring-sig-verify\"],\"dev-tests-only\":[],\"fips\":[\"dep:aws-lc-fips-sys\"],\"non-fips\":[\"aws-lc-sys\"],\"prebuilt-nasm\":[\"aws-lc-sys?/prebuilt-nasm\"],\"ring-io\":[\"dep:untrusted\"],\"ring-sig-verify\":[\"dep:untrusted\"],\"test_logging\":[],\"unstable\":[]}}", + "aws-lc-sys_0.39.0": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.72.0\"},{\"features\":[\"parallel\"],\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.2.26\"},{\"kind\":\"build\",\"name\":\"cmake\",\"req\":\"^0.1.54\"},{\"kind\":\"build\",\"name\":\"dunce\",\"req\":\"^1.0.5\"},{\"kind\":\"build\",\"name\":\"fs_extra\",\"req\":\"^1.3.0\"}],\"features\":{\"all-bindings\":[],\"asan\":[],\"bindgen\":[\"dep:bindgen\"],\"default\":[\"all-bindings\"],\"disable-prebuilt-nasm\":[],\"fips\":[\"dep:bindgen\"],\"prebuilt-nasm\":[],\"ssl\":[\"bindgen\",\"all-bindings\"]}}", "axum-core_0.5.6": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1.2\"},{\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"name\":\"http\",\"req\":\"^1.0.0\"},{\"name\":\"http-body\",\"req\":\"^1.0.0\"},{\"name\":\"http-body-util\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.0.0\"},{\"name\":\"mime\",\"req\":\"^0.3.16\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.7\"},{\"name\":\"sync_wrapper\",\"req\":\"^1.0.0\"},{\"features\":[\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.25.0\"},{\"features\":[\"limit\"],\"name\":\"tower-http\",\"optional\":true,\"req\":\"^0.6.0\"},{\"features\":[\"limit\"],\"kind\":\"dev\",\"name\":\"tower-http\",\"req\":\"^0.6.0\"},{\"name\":\"tower-layer\",\"req\":\"^0.3\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.37\"}],\"features\":{\"__private_docs\":[\"dep:tower-http\"],\"tracing\":[\"dep:tracing\"]}}", "axum_0.8.8": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"axum-core\",\"req\":\"^0.5.5\"},{\"name\":\"axum-macros\",\"optional\":true,\"req\":\"^0.5.0\"},{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22.1\"},{\"name\":\"bytes\",\"req\":\"^1.0\"},{\"name\":\"form_urlencoded\",\"optional\":true,\"req\":\"^1.1.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"http\",\"req\":\"^1.0.0\"},{\"name\":\"http-body\",\"req\":\"^1.0.0\"},{\"name\":\"http-body-util\",\"req\":\"^0.1.0\"},{\"name\":\"hyper\",\"optional\":true,\"req\":\"^1.1.0\"},{\"features\":[\"client\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.1.0\"},{\"features\":[\"tokio\",\"server\",\"service\"],\"name\":\"hyper-util\",\"optional\":true,\"req\":\"^0.1.3\"},{\"name\":\"itoa\",\"req\":\"^1.0.5\"},{\"name\":\"matchit\",\"req\":\"=0.8.4\"},{\"name\":\"memchr\",\"req\":\"^2.4.1\"},{\"name\":\"mime\",\"req\":\"^0.3.16\"},{\"name\":\"multer\",\"optional\":true,\"req\":\"^3.0.0\"},{\"name\":\"percent-encoding\",\"req\":\"^2.1\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.7\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"json\",\"stream\",\"multipart\"],\"name\":\"reqwest\",\"optional\":true,\"req\":\"^0.12\"},{\"default_features\":false,\"features\":[\"json\",\"stream\",\"multipart\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.12\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.211\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.221\"},{\"name\":\"serde_core\",\"req\":\"^1.0.221\"},{\"features\":[\"raw_value\"],\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"raw_value\"],\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"serde_path_to_error\",\"optional\":true,\"req\":\"^0.1.8\"},{\"name\":\"serde_urlencoded\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"sync_wrapper\",\"req\":\"^1.0.0\"},{\"features\":[\"serde-human-readable\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3\"},{\"features\":[\"time\"],\"name\":\"tokio\",\"optional\":true,\"package\":\"tokio\",\"req\":\"^1.44\"},{\"features\":[\"macros\",\"rt\",\"rt-multi-thread\",\"net\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"package\":\"tokio\",\"req\":\"^1.44.2\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"name\":\"tokio-tungstenite\",\"optional\":true,\"req\":\"^0.28.0\"},{\"kind\":\"dev\",\"name\":\"tokio-tungstenite\",\"req\":\"^0.28.0\"},{\"default_features\":false,\"features\":[\"util\"],\"name\":\"tower\",\"req\":\"^0.5.2\"},{\"features\":[\"util\",\"timeout\",\"limit\",\"load-shed\",\"steer\",\"filter\"],\"kind\":\"dev\",\"name\":\"tower\",\"package\":\"tower\",\"req\":\"^0.5.2\"},{\"features\":[\"add-extension\",\"auth\",\"catch-panic\",\"compression-br\",\"compression-deflate\",\"compression-gzip\",\"cors\",\"decompression-br\",\"decompression-deflate\",\"decompression-gzip\",\"follow-redirect\",\"fs\",\"limit\",\"map-request-body\",\"map-response-body\",\"metrics\",\"normalize-path\",\"propagate-header\",\"redirect\",\"request-id\",\"sensitive-headers\",\"set-header\",\"set-status\",\"timeout\",\"trace\",\"util\",\"validate-request\"],\"name\":\"tower-http\",\"optional\":true,\"req\":\"^0.6.0\"},{\"features\":[\"add-extension\",\"auth\",\"catch-panic\",\"compression-br\",\"compression-deflate\",\"compression-gzip\",\"cors\",\"decompression-br\",\"decompression-deflate\",\"decompression-gzip\",\"follow-redirect\",\"fs\",\"limit\",\"map-request-body\",\"map-response-body\",\"metrics\",\"normalize-path\",\"propagate-header\",\"redirect\",\"request-id\",\"sensitive-headers\",\"set-header\",\"set-status\",\"timeout\",\"trace\",\"util\",\"validate-request\"],\"kind\":\"dev\",\"name\":\"tower-http\",\"req\":\"^0.6.0\"},{\"name\":\"tower-layer\",\"req\":\"^0.3.2\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"json\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"features\":[\"serde\",\"v4\"],\"kind\":\"dev\",\"name\":\"uuid\",\"req\":\"^1.0\"}],\"features\":{\"__private\":[\"tokio\",\"http1\",\"dep:reqwest\"],\"__private_docs\":[\"axum-core/__private_docs\",\"tower/full\",\"dep:serde\",\"dep:tower-http\"],\"default\":[\"form\",\"http1\",\"json\",\"matched-path\",\"original-uri\",\"query\",\"tokio\",\"tower-log\",\"tracing\"],\"form\":[\"dep:form_urlencoded\",\"dep:serde_urlencoded\",\"dep:serde_path_to_error\"],\"http1\":[\"dep:hyper\",\"hyper?/http1\",\"hyper-util?/http1\"],\"http2\":[\"dep:hyper\",\"hyper?/http2\",\"hyper-util?/http2\"],\"json\":[\"dep:serde_json\",\"dep:serde_path_to_error\"],\"macros\":[\"dep:axum-macros\"],\"matched-path\":[],\"multipart\":[\"dep:multer\"],\"original-uri\":[],\"query\":[\"dep:form_urlencoded\",\"dep:serde_urlencoded\",\"dep:serde_path_to_error\"],\"tokio\":[\"dep:hyper-util\",\"dep:tokio\",\"tokio/net\",\"tokio/rt\",\"tower/make\",\"tokio/macros\"],\"tower-log\":[\"tower/log\"],\"tracing\":[\"dep:tracing\",\"axum-core/tracing\"],\"ws\":[\"dep:hyper\",\"tokio\",\"dep:tokio-tungstenite\",\"dep:sha1\",\"dep:base64\"]}}", "backtrace_0.3.76": "{\"dependencies\":[{\"default_features\":false,\"name\":\"addr2line\",\"req\":\"^0.25.0\",\"target\":\"cfg(not(all(windows, target_env = \\\"msvc\\\", not(target_vendor = \\\"uwp\\\"))))\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"cpp_demangle\",\"optional\":true,\"req\":\"^0.5.0\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.156\",\"target\":\"cfg(not(all(windows, target_env = \\\"msvc\\\", not(target_vendor = \\\"uwp\\\"))))\"},{\"kind\":\"dev\",\"name\":\"libloading\",\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"miniz_oxide\",\"req\":\"^0.8\",\"target\":\"cfg(not(all(windows, target_env = \\\"msvc\\\", not(target_vendor = \\\"uwp\\\"))))\"},{\"default_features\":false,\"features\":[\"read_core\",\"elf\",\"macho\",\"pe\",\"xcoff\",\"unaligned\",\"archive\"],\"name\":\"object\",\"req\":\"^0.37.0\",\"target\":\"cfg(not(all(windows, target_env = \\\"msvc\\\", not(target_vendor = \\\"uwp\\\"))))\"},{\"name\":\"rustc-demangle\",\"req\":\"^0.1.24\"},{\"default_features\":false,\"name\":\"ruzstd\",\"optional\":true,\"req\":\"^0.8.1\",\"target\":\"cfg(not(all(windows, target_env = \\\"msvc\\\", not(target_vendor = \\\"uwp\\\"))))\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"windows-link\",\"req\":\"^0.2\",\"target\":\"cfg(any(windows, target_os = \\\"cygwin\\\"))\"}],\"features\":{\"coresymbolication\":[],\"dbghelp\":[],\"default\":[\"std\"],\"dl_iterate_phdr\":[],\"dladdr\":[],\"kernel32\":[],\"libunwind\":[],\"ruzstd\":[\"dep:ruzstd\"],\"serialize-serde\":[\"serde\"],\"std\":[],\"unix-backtrace\":[]}}", @@ -679,6 +680,7 @@ "cached_0.56.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"ahash\",\"optional\":true,\"req\":\"^0.8\"},{\"features\":[\"attributes\"],\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.6\"},{\"name\":\"async-trait\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"cached_proc_macro\",\"optional\":true,\"req\":\"^0.25.0\"},{\"name\":\"cached_proc_macro_types\",\"optional\":true,\"req\":\"^0.1.1\"},{\"kind\":\"dev\",\"name\":\"copy_dir\",\"req\":\"^0.1.3\"},{\"name\":\"directories\",\"optional\":true,\"req\":\"^6.0\"},{\"default_features\":false,\"name\":\"futures\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"googletest\",\"req\":\"^0.11.0\"},{\"default_features\":false,\"features\":[\"inline-more\"],\"name\":\"hashbrown\",\"req\":\"^0.15\"},{\"name\":\"once_cell\",\"req\":\"^1\"},{\"name\":\"r2d2\",\"optional\":true,\"req\":\"^0.8\"},{\"features\":[\"r2d2\"],\"name\":\"redis\",\"optional\":true,\"req\":\"^0.32\"},{\"name\":\"rmp-serde\",\"optional\":true,\"req\":\"^1.1\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^3\"},{\"name\":\"sled\",\"optional\":true,\"req\":\"^0.34\"},{\"kind\":\"dev\",\"name\":\"smartstring\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.10.1\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"macros\",\"time\",\"sync\",\"parking_lot\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"web-time\",\"req\":\"^1.1.0\"}],\"features\":{\"ahash\":[\"dep:ahash\",\"hashbrown/default\"],\"async\":[\"futures\",\"tokio\",\"async-trait\"],\"async_tokio_rt_multi_thread\":[\"async\",\"tokio/rt-multi-thread\"],\"default\":[\"proc_macro\",\"ahash\"],\"disk_store\":[\"sled\",\"serde\",\"rmp-serde\",\"directories\"],\"proc_macro\":[\"cached_proc_macro\",\"cached_proc_macro_types\"],\"redis_ahash\":[\"redis_store\",\"redis/ahash\"],\"redis_async_std\":[\"redis_store\",\"async\",\"redis/aio\",\"redis/async-std-comp\",\"redis/tls\",\"redis/async-std-tls-comp\"],\"redis_connection_manager\":[\"redis_store\",\"redis/connection-manager\"],\"redis_store\":[\"redis\",\"r2d2\",\"serde\",\"serde_json\"],\"redis_tokio\":[\"redis_store\",\"async\",\"redis/aio\",\"redis/tokio-comp\",\"redis/tls\",\"redis/tokio-native-tls-comp\"],\"wasm\":[]}}", "cached_proc_macro_0.25.0": "{\"dependencies\":[{\"name\":\"darling\",\"req\":\"^0.20.8\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.49\"},{\"name\":\"quote\",\"req\":\"^1.0.6\"},{\"name\":\"syn\",\"req\":\"^2.0.52\"}],\"features\":{}}", "cached_proc_macro_types_0.1.1": "{\"dependencies\":[],\"features\":{}}", + "calendrical_calculations_0.2.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"core_maths\",\"req\":\"^0.1.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"default_features\":false,\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.17\"}],\"features\":{\"logging\":[\"dep:log\"]}}", "cassowary_0.3.0": "{\"dependencies\":[],\"features\":{}}", "castaway_0.2.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1\"},{\"name\":\"rustversion\",\"req\":\"^1\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", "cbc_0.1.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"aes\",\"req\":\"^0.8\"},{\"name\":\"cipher\",\"req\":\"^0.4.2\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"cipher\",\"req\":\"^0.4.2\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3.3\"}],\"features\":{\"alloc\":[\"cipher/alloc\"],\"block-padding\":[\"cipher/block-padding\"],\"default\":[\"block-padding\"],\"std\":[\"cipher/std\",\"alloc\"],\"zeroize\":[\"cipher/zeroize\"]}}", @@ -722,6 +724,7 @@ "core-foundation-sys_0.8.7": "{\"dependencies\":[],\"features\":{\"default\":[\"link\"],\"link\":[],\"mac_os_10_7_support\":[],\"mac_os_10_8_features\":[]}}", "core-foundation_0.10.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"core-foundation-sys\",\"req\":\"^0.8\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"default\":[\"link\"],\"link\":[\"core-foundation-sys/link\"],\"mac_os_10_7_support\":[\"core-foundation-sys/mac_os_10_7_support\"],\"mac_os_10_8_features\":[\"core-foundation-sys/mac_os_10_8_features\"],\"with-uuid\":[\"dep:uuid\"]}}", "core-foundation_0.9.4": "{\"dependencies\":[{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"core-foundation-sys\",\"req\":\"^0.8.6\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^0.5\"}],\"features\":{\"default\":[\"link\"],\"link\":[\"core-foundation-sys/link\"],\"mac_os_10_7_support\":[\"core-foundation-sys/mac_os_10_7_support\"],\"mac_os_10_8_features\":[\"core-foundation-sys/mac_os_10_8_features\"],\"with-chrono\":[\"chrono\"],\"with-uuid\":[\"uuid\"]}}", + "core_maths_0.1.1": "{\"dependencies\":[{\"name\":\"libm\",\"req\":\"^0.2\"}],\"features\":{}}", "coreaudio-rs_0.11.3": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^1.0\"},{\"name\":\"core-foundation-sys\",\"req\":\"^0.8.3\"},{\"default_features\":false,\"name\":\"coreaudio-sys\",\"req\":\"^0.2\"}],\"features\":{\"audio_toolbox\":[\"coreaudio-sys/audio_toolbox\"],\"audio_unit\":[\"coreaudio-sys/audio_unit\"],\"core_audio\":[\"coreaudio-sys/core_audio\"],\"core_midi\":[\"coreaudio-sys/core_midi\"],\"default\":[\"audio_toolbox\",\"audio_unit\",\"core_audio\",\"open_al\",\"core_midi\"],\"open_al\":[\"coreaudio-sys/open_al\"]}}", "coreaudio-sys_0.2.17": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"runtime\"],\"kind\":\"build\",\"name\":\"bindgen\",\"req\":\"^0.72\"}],\"features\":{\"audio_server_plugin\":[],\"audio_toolbox\":[],\"audio_unit\":[],\"core_audio\":[],\"core_midi\":[],\"default\":[\"audio_toolbox\",\"audio_unit\",\"core_audio\",\"audio_server_plugin\",\"open_al\",\"core_midi\"],\"io_kit_audio\":[],\"open_al\":[]}}", "cpal_0.15.3": "{\"dependencies\":[{\"name\":\"alsa\",\"req\":\"^0.9\",\"target\":\"cfg(any(target_os = \\\"linux\\\", target_os = \\\"dragonfly\\\", target_os = \\\"freebsd\\\", target_os = \\\"netbsd\\\"))\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"asio-sys\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.0\"},{\"name\":\"core-foundation-sys\",\"req\":\"^0.8.2\",\"target\":\"cfg(any(target_os = \\\"macos\\\", target_os = \\\"ios\\\"))\"},{\"default_features\":false,\"features\":[\"audio_unit\",\"core_audio\",\"audio_toolbox\"],\"name\":\"coreaudio-rs\",\"req\":\"^0.11\",\"target\":\"cfg(target_os = \\\"ios\\\")\"},{\"default_features\":false,\"features\":[\"audio_unit\",\"core_audio\"],\"name\":\"coreaudio-rs\",\"req\":\"^0.11\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"name\":\"dasp_sample\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"hound\",\"req\":\"^3.5\"},{\"name\":\"jack\",\"optional\":true,\"req\":\"^0.11\",\"target\":\"cfg(any(target_os = \\\"linux\\\", target_os = \\\"dragonfly\\\", target_os = \\\"freebsd\\\", target_os = \\\"netbsd\\\"))\"},{\"name\":\"jni\",\"req\":\"^0.21\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"js-sys\",\"req\":\"^0.3.35\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"js-sys\",\"req\":\"^0.3.35\",\"target\":\"cfg(target_os = \\\"emscripten\\\")\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_os = \\\"linux\\\", target_os = \\\"dragonfly\\\", target_os = \\\"freebsd\\\", target_os = \\\"netbsd\\\"))\"},{\"name\":\"mach2\",\"req\":\"^0.4\",\"target\":\"cfg(any(target_os = \\\"macos\\\", target_os = \\\"ios\\\"))\"},{\"default_features\":false,\"name\":\"ndk\",\"req\":\"^0.8\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"ndk-context\",\"req\":\"^0.1\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"kind\":\"dev\",\"name\":\"ndk-glue\",\"req\":\"^0.7\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"num-traits\",\"optional\":true,\"req\":\"^0.2.6\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"features\":[\"java-interface\"],\"name\":\"oboe\",\"req\":\"^0.6\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"kind\":\"dev\",\"name\":\"ringbuf\",\"req\":\"^0.3\"},{\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2.58\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"wasm-bindgen\",\"req\":\"^0.2.89\",\"target\":\"cfg(target_os = \\\"emscripten\\\")\"},{\"name\":\"wasm-bindgen-futures\",\"req\":\"^0.4.33\",\"target\":\"cfg(target_os = \\\"emscripten\\\")\"},{\"features\":[\"AudioContext\",\"AudioContextOptions\",\"AudioBuffer\",\"AudioBufferSourceNode\",\"AudioNode\",\"AudioDestinationNode\",\"Window\",\"AudioContextState\"],\"name\":\"web-sys\",\"req\":\"^0.3.35\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"AudioContext\",\"AudioContextOptions\",\"AudioBuffer\",\"AudioBufferSourceNode\",\"AudioNode\",\"AudioDestinationNode\",\"Window\",\"AudioContextState\"],\"name\":\"web-sys\",\"req\":\"^0.3.35\",\"target\":\"cfg(target_os = \\\"emscripten\\\")\"},{\"features\":[\"Win32_Media_Audio\",\"Win32_Foundation\",\"Win32_Devices_Properties\",\"Win32_Media_KernelStreaming\",\"Win32_System_Com_StructuredStorage\",\"Win32_System_Threading\",\"Win32_Security\",\"Win32_System_SystemServices\",\"Win32_System_Variant\",\"Win32_Media_Multimedia\",\"Win32_UI_Shell_PropertiesSystem\"],\"name\":\"windows\",\"req\":\"^0.54.0\",\"target\":\"cfg(target_os = \\\"windows\\\")\"}],\"features\":{\"asio\":[\"asio-sys\",\"num-traits\"],\"oboe-shared-stdcxx\":[\"oboe/shared-stdcxx\"]}}", @@ -777,6 +780,9 @@ "difflib_0.4.0": "{\"dependencies\":[],\"features\":{}}", "diffy_0.4.2": "{\"dependencies\":[{\"name\":\"nu-ansi-term\",\"req\":\"^0.50\"}],\"features\":{}}", "digest_0.10.7": "{\"dependencies\":[{\"name\":\"blobby\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"block-buffer\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"const-oid\",\"optional\":true,\"req\":\"^0.9\"},{\"name\":\"crypto-common\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"name\":\"subtle\",\"optional\":true,\"req\":\"^2.4\"}],\"features\":{\"alloc\":[],\"core-api\":[\"block-buffer\"],\"default\":[\"core-api\"],\"dev\":[\"blobby\"],\"mac\":[\"subtle\"],\"oid\":[\"const-oid\"],\"rand_core\":[\"crypto-common/rand_core\"],\"std\":[\"alloc\",\"crypto-common/std\"]}}", + "diplomat-runtime_0.14.0": "{\"dependencies\":[{\"name\":\"jni\",\"optional\":true,\"req\":\"^0.21\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"}],\"features\":{\"jvm-callback-support\":[\"dep:jni\"],\"log\":[\"dep:log\"]}}", + "diplomat_0.14.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"diplomat_core\",\"req\":\"^0.14.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.7.1\"},{\"kind\":\"dev\",\"name\":\"prettyplease\",\"req\":\"^0.2.30\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.27\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"features\":[\"full\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.2.0\"}],\"features\":{}}", + "diplomat_core_0.14.0": "{\"dependencies\":[{\"name\":\"displaydoc\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"either\",\"optional\":true,\"req\":\"^1.9.0\"},{\"features\":[\"yaml\"],\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.7.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.27\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"smallvec\",\"req\":\"^1.9.0\"},{\"features\":[\"ident\"],\"name\":\"strck\",\"req\":\"^1.0\"},{\"features\":[\"full\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{\"hir\":[\"either\"]}}", "dirs-next_2.0.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"dirs-sys-next\",\"req\":\"^0.1\"}],\"features\":{}}", "dirs-sys-next_0.1.2": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"default_features\":false,\"name\":\"redox_users\",\"req\":\"^0.4.0\",\"target\":\"cfg(target_os = \\\"redox\\\")\"},{\"features\":[\"knownfolders\",\"objbase\",\"shlobj\",\"winbase\",\"winerror\"],\"name\":\"winapi\",\"req\":\"^0.3\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "dirs-sys_0.5.0": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"name\":\"option-ext\",\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"redox_users\",\"req\":\"^0.5\",\"target\":\"cfg(target_os = \\\"redox\\\")\"},{\"features\":[\"Win32_UI_Shell\",\"Win32_Foundation\",\"Win32_Globalization\",\"Win32_System_Com\"],\"name\":\"windows-sys\",\"req\":\">=0.59.0\",\"target\":\"cfg(windows)\"}],\"features\":{}}", @@ -845,6 +851,7 @@ "form_urlencoded_1.2.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"percent-encoding\",\"req\":\"^2.3.0\"}],\"features\":{\"alloc\":[\"percent-encoding/alloc\"],\"default\":[\"std\"],\"std\":[\"alloc\",\"percent-encoding/std\"]}}", "fs_extra_1.3.0": "{\"dependencies\":[],\"features\":{}}", "fsevent-sys_4.1.0": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.68\"}],\"features\":{}}", + "fslock_0.2.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.66\",\"target\":\"cfg(unix)\"},{\"features\":[\"minwindef\",\"minwinbase\",\"winbase\",\"errhandlingapi\",\"winerror\",\"winnt\",\"synchapi\",\"handleapi\",\"fileapi\",\"processthreadsapi\"],\"name\":\"winapi\",\"req\":\"^0.3.8\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "futures-channel_0.3.31": "{\"dependencies\":[{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-sink\",\"optional\":true,\"req\":\"^0.3.31\"}],\"features\":{\"alloc\":[\"futures-core/alloc\"],\"cfg-target-has-atomic\":[],\"default\":[\"std\"],\"sink\":[\"futures-sink\"],\"std\":[\"alloc\",\"futures-core/std\"],\"unstable\":[]}}", "futures-core_0.3.31": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"require-cas\"],\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1.3\"}],\"features\":{\"alloc\":[],\"cfg-target-has-atomic\":[],\"default\":[\"std\"],\"std\":[\"alloc\"],\"unstable\":[]}}", "futures-executor_0.3.31": "{\"dependencies\":[{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-task\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-util\",\"req\":\"^0.3.31\"},{\"name\":\"num_cpus\",\"optional\":true,\"req\":\"^1.8.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"futures-core/std\",\"futures-task/std\",\"futures-util/std\"],\"thread-pool\":[\"std\",\"num_cpus\"]}}", @@ -874,6 +881,7 @@ "git+https://github.com/openai-oss-forks/tungstenite-rs?rev=9200079d3b54a1ff51072e24d81fd354f085156f#9200079d3b54a1ff51072e24d81fd354f085156f_tungstenite": "{\"dependencies\":[{\"name\":\"bytes\"},{\"default_features\":true,\"features\":[],\"name\":\"data-encoding\",\"optional\":true},{\"default_features\":false,\"features\":[\"zlib\"],\"name\":\"flate2\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"headers\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"http\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"httparse\",\"optional\":true},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"name\":\"rand\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"sha1\",\"optional\":true},{\"name\":\"thiserror\"},{\"default_features\":true,\"features\":[],\"name\":\"url\",\"optional\":true},{\"name\":\"utf-8\"},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\"],\"default\":[\"handshake\"],\"deflate\":[\"headers\",\"flate2\"],\"handshake\":[\"data-encoding\",\"headers\",\"httparse\",\"sha1\"],\"headers\":[\"http\",\"dep:headers\"],\"native-tls\":[\"native-tls-crate\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\"],\"proxy\":[\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"url\":[\"dep:url\"]},\"strip_prefix\":\"\"}", "glob_0.3.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"tempdir\",\"req\":\"^0.3\"}],\"features\":{}}", "globset_0.4.18": "{\"dependencies\":[{\"name\":\"aho-corasick\",\"req\":\"^1.1.1\"},{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.3.2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"bstr\",\"req\":\"^1.6.2\"},{\"kind\":\"dev\",\"name\":\"glob\",\"req\":\"^0.3.1\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.20\"},{\"default_features\":false,\"features\":[\"std\",\"perf\",\"syntax\",\"meta\",\"nfa\",\"hybrid\"],\"name\":\"regex-automata\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"regex-syntax\",\"req\":\"^0.8.0\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.188\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.107\"}],\"features\":{\"arbitrary\":[\"dep:arbitrary\"],\"default\":[\"log\"],\"serde1\":[\"serde\"],\"simd-accel\":[]}}", + "gzip-header_1.0.0": "{\"dependencies\":[{\"name\":\"crc32fast\",\"req\":\"^1.2.1\"}],\"features\":{}}", "h2_0.4.13": "{\"dependencies\":[{\"name\":\"atomic-waker\",\"req\":\"^1.0.0\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\"},{\"name\":\"fnv\",\"req\":\"^1.0.5\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-sink\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"http\",\"req\":\"^1\"},{\"features\":[\"std\"],\"name\":\"indexmap\",\"req\":\"^2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.0\"},{\"name\":\"slab\",\"req\":\"^0.4.2\"},{\"features\":[\"io-util\"],\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"rt-multi-thread\",\"macros\",\"sync\",\"net\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio-rustls\",\"req\":\"^0.26\"},{\"features\":[\"codec\",\"io\"],\"name\":\"tokio-util\",\"req\":\"^0.7.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"req\":\"^0.1.35\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.3.2\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"}],\"features\":{\"stream\":[],\"unstable\":[]}}", "half_2.7.1": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.4.1\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"bytemuck\",\"optional\":true,\"req\":\"^1.4.1\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"name\":\"crunchy\",\"req\":\"^0.2.2\",\"target\":\"cfg(target_arch = \\\"spirv\\\")\"},{\"kind\":\"dev\",\"name\":\"crunchy\",\"req\":\"^0.2.2\"},{\"default_features\":false,\"features\":[\"libm\"],\"name\":\"num-traits\",\"optional\":true,\"req\":\"^0.2.16\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"thread_rng\"],\"name\":\"rand\",\"optional\":true,\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9.0\"},{\"default_features\":false,\"name\":\"rand_distr\",\"optional\":true,\"req\":\"^0.5.0\"},{\"name\":\"rkyv\",\"optional\":true,\"req\":\"^0.8.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"derive\",\"simd\"],\"name\":\"zerocopy\",\"req\":\"^0.8.26\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"nightly\":[],\"rand_distr\":[\"dep:rand\",\"dep:rand_distr\"],\"std\":[\"alloc\"],\"use-intrinsics\":[],\"zerocopy\":[]}}", "hashbrown_0.12.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"ahash\",\"optional\":true,\"req\":\"^0.7.0\"},{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"name\":\"bumpalo\",\"optional\":true,\"req\":\"^3.5.0\"},{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1.2\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"fnv\",\"req\":\"^1.0.7\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.3\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.25\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"ahash-compile-time-rng\":[\"ahash/compile-time-rng\"],\"default\":[\"ahash\",\"inline-more\"],\"inline-more\":[],\"nightly\":[],\"raw\":[],\"rustc-dep-of-std\":[\"nightly\",\"core\",\"compiler_builtins\",\"alloc\",\"rustc-internal-api\"],\"rustc-internal-api\":[]}}", @@ -911,6 +919,8 @@ "i18n-embed_0.15.4": "{\"dependencies\":[{\"name\":\"arc-swap\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"name\":\"fluent\",\"optional\":true,\"req\":\"^0.16\"},{\"name\":\"fluent-langneg\",\"req\":\"^0.13\"},{\"name\":\"fluent-syntax\",\"optional\":true,\"req\":\"^0.11\"},{\"name\":\"gettext\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"i18n-embed-impl\",\"optional\":true,\"req\":\"^0.8.4\"},{\"name\":\"intl-memoizer\",\"req\":\"^0.5\"},{\"name\":\"locale_config\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"maplit\",\"req\":\"^1.0\"},{\"name\":\"notify\",\"optional\":true,\"req\":\"^8.0.0\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.4\"},{\"name\":\"rust-embed\",\"optional\":true,\"req\":\"^8.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^3.0\"},{\"name\":\"thiserror\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"tr\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"unic-langid\",\"req\":\"^0.9\"},{\"name\":\"walkdir\",\"optional\":true,\"req\":\"^2.4\"},{\"features\":[\"Window\",\"Navigator\"],\"name\":\"web-sys\",\"optional\":true,\"req\":\"^0.3\"}],\"features\":{\"autoreload\":[\"notify\"],\"default\":[\"rust-embed\"],\"desktop-requester\":[\"locale_config\"],\"filesystem-assets\":[\"walkdir\"],\"fluent-system\":[\"fluent\",\"fluent-syntax\",\"parking_lot\",\"i18n-embed-impl\",\"i18n-embed-impl/fluent-system\",\"arc-swap\"],\"gettext-system\":[\"tr\",\"tr/gettext\",\"dep:gettext\",\"parking_lot\",\"i18n-embed-impl\",\"i18n-embed-impl/gettext-system\"],\"web-sys-requester\":[\"web-sys\"]}}", "iana-time-zone-haiku_0.1.2": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.79\"}],\"features\":{}}", "iana-time-zone_0.1.65": "{\"dependencies\":[{\"name\":\"android_system_properties\",\"req\":\"^0.1.5\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"kind\":\"dev\",\"name\":\"chrono-tz\",\"req\":\"^0.10.1\"},{\"name\":\"core-foundation-sys\",\"req\":\"^0.8.6\",\"target\":\"cfg(target_vendor = \\\"apple\\\")\"},{\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2.1\"},{\"features\":[\"js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2.1\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"iana-time-zone-haiku\",\"req\":\"^0.1.1\",\"target\":\"cfg(target_os = \\\"haiku\\\")\"},{\"name\":\"js-sys\",\"req\":\"^0.3.66\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"log\",\"req\":\"^0.4.14\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"wasm-bindgen\",\"req\":\"^0.2.89\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.46\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"windows-core\",\"req\":\">=0.56, <=0.62\",\"target\":\"cfg(target_os = \\\"windows\\\")\"}],\"features\":{\"fallback\":[]}}", + "icu_calendar_2.1.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"calendrical_calculations\",\"req\":\"^0.2.3\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"default_features\":false,\"name\":\"icu_calendar_data\",\"optional\":true,\"req\":\"~2.1.1\"},{\"default_features\":false,\"name\":\"icu_locale\",\"optional\":true,\"req\":\"~2.1.1\"},{\"default_features\":false,\"name\":\"icu_locale_core\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"name\":\"icu_provider\",\"req\":\"^2.1.1\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"name\":\"ixdtf\",\"optional\":true,\"req\":\"^0.6.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"kind\":\"dev\",\"name\":\"simple_logger\",\"req\":\"^5.0.0\"},{\"default_features\":false,\"features\":[\"zerovec\"],\"name\":\"tinystr\",\"req\":\"^0.8.0\"},{\"kind\":\"dev\",\"name\":\"ureq\",\"req\":\"^3.0.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"zerovec\",\"req\":\"^0.11.3\"}],\"features\":{\"alloc\":[\"icu_locale_core/alloc\",\"tinystr/alloc\",\"serde?/alloc\"],\"compiled_data\":[\"dep:icu_calendar_data\",\"dep:icu_locale\",\"icu_locale?/compiled_data\",\"icu_provider/baked\"],\"datagen\":[\"serde\",\"dep:databake\",\"zerovec/databake\",\"tinystr/databake\",\"alloc\",\"icu_provider/export\"],\"default\":[\"compiled_data\",\"ixdtf\"],\"ixdtf\":[\"dep:ixdtf\"],\"logging\":[\"calendrical_calculations/logging\"],\"serde\":[\"dep:serde\",\"zerovec/serde\",\"tinystr/serde\",\"icu_provider/serde\"],\"unstable\":[]}}", + "icu_calendar_data_2.1.1": "{\"dependencies\":[],\"features\":{}}", "icu_collections_2.1.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"kind\":\"dev\",\"name\":\"iai\",\"req\":\"^0.1.1\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"default_features\":false,\"features\":[\"zerovec\"],\"name\":\"potential_utf\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"features\":[\"parse\"],\"kind\":\"dev\",\"name\":\"toml\",\"req\":\"^0.8.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"yoke\",\"req\":\"^0.8.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"zerofrom\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"features\":[\"derive\",\"yoke\"],\"name\":\"zerovec\",\"req\":\"^0.11.3\"}],\"features\":{\"alloc\":[\"serde?/alloc\",\"zerovec/alloc\"],\"databake\":[\"dep:databake\",\"zerovec/databake\"],\"serde\":[\"dep:serde\",\"zerovec/serde\",\"potential_utf/serde\",\"alloc\"]}}", "icu_decimal_2.1.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"fixed_decimal\",\"req\":\"^0.7.0\"},{\"features\":[\"wasm_js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"icu_decimal_data\",\"optional\":true,\"req\":\"~2.1.1\"},{\"default_features\":false,\"name\":\"icu_locale\",\"optional\":true,\"req\":\"~2.1.1\"},{\"default_features\":false,\"name\":\"icu_locale_core\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"name\":\"icu_provider\",\"req\":\"^2.1.1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand_distr\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"name\":\"writeable\",\"req\":\"^0.6.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"zerovec\",\"req\":\"^0.11.3\"}],\"features\":{\"alloc\":[\"serde?/alloc\",\"zerovec/alloc\"],\"compiled_data\":[\"dep:icu_decimal_data\",\"dep:icu_locale\",\"icu_locale?/compiled_data\",\"icu_provider/baked\"],\"datagen\":[\"serde\",\"dep:databake\",\"zerovec/databake\",\"icu_provider/export\",\"alloc\"],\"default\":[\"compiled_data\"],\"ryu\":[\"fixed_decimal/ryu\"],\"serde\":[\"dep:serde\",\"icu_provider/serde\",\"zerovec/serde\"]}}", "icu_decimal_data_2.1.1": "{\"dependencies\":[],\"features\":{}}", @@ -954,6 +964,7 @@ "itertools_0.13.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"either\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"permutohedron\",\"req\":\"^0.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.7\"}],\"features\":{\"default\":[\"use_std\"],\"use_alloc\":[],\"use_std\":[\"use_alloc\",\"either/use_std\"]}}", "itertools_0.14.0": "{\"dependencies\":[{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"either\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"permutohedron\",\"req\":\"^0.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.7\"}],\"features\":{\"default\":[\"use_std\"],\"use_alloc\":[],\"use_std\":[\"use_alloc\",\"either/use_std\"]}}", "itoa_1.0.17": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\",\"target\":\"cfg(not(miri))\"},{\"name\":\"no-panic\",\"optional\":true,\"req\":\"^0.1\"}],\"features\":{}}", + "ixdtf_0.6.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"serde-json-core\",\"req\":\"^0.6.0\"}],\"features\":{\"default\":[\"duration\"],\"duration\":[]}}", "jiff-static_0.2.18": "{\"dependencies\":[{\"name\":\"jiff-tzdb\",\"optional\":true,\"req\":\"^0.1.4\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.93\"},{\"name\":\"quote\",\"req\":\"^1.0.38\"},{\"name\":\"syn\",\"req\":\"^2.0.98\"}],\"features\":{\"default\":[],\"perf-inline\":[],\"tz-fat\":[],\"tzdb\":[\"dep:jiff-tzdb\"]}}", "jiff_0.2.18": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.81\"},{\"features\":[\"serde\"],\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4.38\"},{\"kind\":\"dev\",\"name\":\"chrono-tz\",\"req\":\"^0.10.0\"},{\"kind\":\"dev\",\"name\":\"hifitime\",\"req\":\"^3.9.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"humantime\",\"req\":\"^2.1.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.39.0\"},{\"name\":\"jiff-static\",\"req\":\"=0.2.18\",\"target\":\"cfg(any())\"},{\"name\":\"jiff-static\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"jiff-tzdb\",\"optional\":true,\"req\":\"^0.1.5\"},{\"name\":\"jiff-tzdb-platform\",\"optional\":true,\"req\":\"^0.1.3\",\"target\":\"cfg(any(windows, target_family = \\\"wasm\\\"))\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3.50\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"default_features\":false,\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.21\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.21\"},{\"default_features\":false,\"name\":\"portable-atomic\",\"req\":\"^1.10.0\",\"target\":\"cfg(not(target_has_atomic = \\\"ptr\\\"))\"},{\"default_features\":false,\"name\":\"portable-atomic-util\",\"req\":\"^0.2.4\",\"target\":\"cfg(not(target_has_atomic = \\\"ptr\\\"))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.203\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.221\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.117\"},{\"kind\":\"dev\",\"name\":\"serde_yaml\",\"req\":\"^0.9.34\"},{\"kind\":\"dev\",\"name\":\"tabwriter\",\"req\":\"^1.4.0\"},{\"features\":[\"local-offset\",\"macros\",\"parsing\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.36\"},{\"kind\":\"dev\",\"name\":\"tzfile\",\"req\":\"^0.1.3\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.5.0\"},{\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2.70\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"default_features\":false,\"features\":[\"Win32_Foundation\",\"Win32_System_Time\"],\"name\":\"windows-sys\",\"optional\":true,\"req\":\">=0.52.0, <=0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"alloc\":[\"serde_core?/alloc\",\"portable-atomic-util/alloc\"],\"default\":[\"std\",\"tz-system\",\"tz-fat\",\"tzdb-bundle-platform\",\"tzdb-zoneinfo\",\"tzdb-concatenated\",\"perf-inline\"],\"js\":[\"dep:wasm-bindgen\",\"dep:js-sys\"],\"logging\":[\"dep:log\"],\"perf-inline\":[],\"serde\":[\"dep:serde_core\"],\"static\":[\"static-tz\",\"jiff-static?/tzdb\"],\"static-tz\":[\"dep:jiff-static\"],\"std\":[\"alloc\",\"log?/std\",\"serde_core?/std\"],\"tz-fat\":[\"jiff-static?/tz-fat\"],\"tz-system\":[\"std\",\"dep:windows-sys\"],\"tzdb-bundle-always\":[\"dep:jiff-tzdb\",\"alloc\"],\"tzdb-bundle-platform\":[\"dep:jiff-tzdb-platform\",\"alloc\"],\"tzdb-concatenated\":[\"std\"],\"tzdb-zoneinfo\":[\"std\"]}}", "jni-sys_0.3.0": "{\"dependencies\":[],\"features\":{}}", @@ -1120,6 +1131,7 @@ "predicates-tree_1.0.12": "{\"dependencies\":[{\"features\":[\"color\"],\"kind\":\"dev\",\"name\":\"predicates\",\"req\":\"^3.1\"},{\"name\":\"predicates-core\",\"req\":\"^1.0\"},{\"name\":\"termtree\",\"req\":\"^0.5.0\"}],\"features\":{}}", "predicates_3.1.3": "{\"dependencies\":[{\"name\":\"anstyle\",\"req\":\"^1.0.0\"},{\"name\":\"difflib\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"float-cmp\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"normalize-line-endings\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"predicates-core\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"predicates-tree\",\"req\":\"^1.0\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"color\":[],\"default\":[\"diff\",\"regex\",\"float-cmp\",\"normalize-line-endings\",\"color\"],\"diff\":[\"dep:difflib\"],\"unstable\":[]}}", "pretty_assertions_1.4.1": "{\"dependencies\":[{\"name\":\"diff\",\"req\":\"^0.1.12\"},{\"name\":\"yansi\",\"req\":\"^1.0.1\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[],\"unstable\":[]}}", + "prettyplease_0.2.37": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"indoc\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.80\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"proc-macro2\",\"req\":\"^1.0.80\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"default_features\":false,\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0.105\"},{\"default_features\":false,\"features\":[\"clone-impls\",\"extra-traits\",\"parsing\",\"printing\",\"visit-mut\"],\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0.105\"}],\"features\":{\"verbatim\":[\"syn/parsing\"]}}", "proc-macro-crate_3.4.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"proc-macro2\",\"req\":\"^1.0.94\"},{\"kind\":\"dev\",\"name\":\"quote\",\"req\":\"^1.0.39\"},{\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0.99\"},{\"default_features\":false,\"features\":[\"parse\"],\"name\":\"toml_edit\",\"req\":\"^0.23.2\"}],\"features\":{}}", "proc-macro-error-attr2_2.0.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"}],\"features\":{}}", "proc-macro-error2_2.0.1": "{\"dependencies\":[{\"name\":\"proc-macro-error-attr2\",\"req\":\"=2.0.0\"},{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"syn\",\"optional\":true,\"req\":\"^2\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.99\"}],\"features\":{\"default\":[\"syn-error\"],\"nightly\":[],\"syn-error\":[\"dep:syn\"]}}", @@ -1181,6 +1193,7 @@ "regex-syntax_0.8.8": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.3.0\"}],\"features\":{\"arbitrary\":[\"dep:arbitrary\"],\"default\":[\"std\",\"unicode\"],\"std\":[],\"unicode\":[\"unicode-age\",\"unicode-bool\",\"unicode-case\",\"unicode-gencat\",\"unicode-perl\",\"unicode-script\",\"unicode-segment\"],\"unicode-age\":[],\"unicode-bool\":[],\"unicode-case\":[],\"unicode-gencat\":[],\"unicode-perl\":[],\"unicode-script\":[],\"unicode-segment\":[]}}", "regex_1.12.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aho-corasick\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.69\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"atty\",\"humantime\",\"termcolor\"],\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.9.3\"},{\"default_features\":false,\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.6.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"default_features\":false,\"features\":[\"alloc\",\"syntax\",\"meta\",\"nfa-pikevm\"],\"name\":\"regex-automata\",\"req\":\"^0.4.12\"},{\"default_features\":false,\"name\":\"regex-syntax\",\"req\":\"^0.8.5\"},{\"kind\":\"dev\",\"name\":\"regex-test\",\"req\":\"^0.1.0\"}],\"features\":{\"default\":[\"std\",\"perf\",\"unicode\",\"regex-syntax/default\"],\"logging\":[\"aho-corasick?/logging\",\"memchr?/logging\",\"regex-automata/logging\"],\"pattern\":[],\"perf\":[\"perf-cache\",\"perf-dfa\",\"perf-onepass\",\"perf-backtrack\",\"perf-inline\",\"perf-literal\"],\"perf-backtrack\":[\"regex-automata/nfa-backtrack\"],\"perf-cache\":[],\"perf-dfa\":[\"regex-automata/hybrid\"],\"perf-dfa-full\":[\"regex-automata/dfa-build\",\"regex-automata/dfa-search\"],\"perf-inline\":[\"regex-automata/perf-inline\"],\"perf-literal\":[\"dep:aho-corasick\",\"dep:memchr\",\"regex-automata/perf-literal\"],\"perf-onepass\":[\"regex-automata/dfa-onepass\"],\"std\":[\"aho-corasick?/std\",\"memchr?/std\",\"regex-automata/std\",\"regex-syntax/std\"],\"unicode\":[\"unicode-age\",\"unicode-bool\",\"unicode-case\",\"unicode-gencat\",\"unicode-perl\",\"unicode-script\",\"unicode-segment\",\"regex-automata/unicode\",\"regex-syntax/unicode\"],\"unicode-age\":[\"regex-automata/unicode-age\",\"regex-syntax/unicode-age\"],\"unicode-bool\":[\"regex-automata/unicode-bool\",\"regex-syntax/unicode-bool\"],\"unicode-case\":[\"regex-automata/unicode-case\",\"regex-syntax/unicode-case\"],\"unicode-gencat\":[\"regex-automata/unicode-gencat\",\"regex-syntax/unicode-gencat\"],\"unicode-perl\":[\"regex-automata/unicode-perl\",\"regex-automata/unicode-word-boundary\",\"regex-syntax/unicode-perl\"],\"unicode-script\":[\"regex-automata/unicode-script\",\"regex-syntax/unicode-script\"],\"unicode-segment\":[\"regex-automata/unicode-segment\",\"regex-syntax/unicode-segment\"],\"unstable\":[\"pattern\"],\"use_std\":[\"std\"]}}", "reqwest_0.12.28": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"brotli_crate\",\"package\":\"brotli\",\"req\":\"^8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"bytes\",\"req\":\"^1.2\"},{\"name\":\"cookie_crate\",\"optional\":true,\"package\":\"cookie\",\"req\":\"^0.18.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"cookie_store\",\"optional\":true,\"req\":\"^0.22.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"encoding_rs\",\"optional\":true,\"req\":\"^0.8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0.13\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"futures-channel\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.28\"},{\"default_features\":false,\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.28\"},{\"default_features\":false,\"features\":[\"std\",\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.28\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"h2\",\"optional\":true,\"req\":\"^0.4\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"h3\",\"optional\":true,\"req\":\"^0.0.8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"h3-quinn\",\"optional\":true,\"req\":\"^0.0.10\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"tokio\"],\"name\":\"hickory-resolver\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"http\",\"req\":\"^1.1\"},{\"name\":\"http-body\",\"req\":\"^1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"http-body-util\",\"req\":\"^0.1.2\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"http1\",\"client\"],\"name\":\"hyper\",\"req\":\"^1.1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"http1\",\"http2\",\"client\",\"server\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"http1\",\"tls12\"],\"name\":\"hyper-rustls\",\"optional\":true,\"req\":\"^0.27.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"hyper-tls\",\"optional\":true,\"req\":\"^0.6\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"http1\",\"client\",\"client-legacy\",\"client-proxy\",\"tokio\"],\"name\":\"hyper-util\",\"req\":\"^0.1.12\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"http1\",\"http2\",\"client\",\"client-legacy\",\"server-auto\",\"server-graceful\",\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1.12\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"js-sys\",\"req\":\"^0.3.77\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0\"},{\"name\":\"log\",\"req\":\"^0.4.17\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"mime\",\"optional\":true,\"req\":\"^0.3.16\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"mime_guess\",\"optional\":true,\"req\":\"^2.0\"},{\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\",\"req\":\"^0.2.10\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.0\"},{\"name\":\"once_cell\",\"optional\":true,\"req\":\"^1.18\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"rustls\",\"runtime-tokio\"],\"name\":\"quinn\",\"optional\":true,\"req\":\"^0.11.1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"std\",\"tls12\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.4\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"std\"],\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.9.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"serde\",\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"serde_json\",\"req\":\"^1.0\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_urlencoded\",\"req\":\"^0.7.1\"},{\"features\":[\"futures\"],\"name\":\"sync_wrapper\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"net\",\"time\"],\"name\":\"tokio\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"tokio-native-tls\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"tls12\"],\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.26\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"io\"],\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7.9\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"retry\",\"timeout\",\"util\"],\"name\":\"tower\",\"req\":\"^0.5.2\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"limit\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.5.2\"},{\"default_features\":false,\"features\":[\"follow-redirect\"],\"name\":\"tower-http\",\"req\":\"^0.6.8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"tower-service\",\"req\":\"^0.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"url\",\"req\":\"^2.4\"},{\"name\":\"wasm-bindgen\",\"req\":\"^0.2.89\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"features\":[\"serde-serialize\"],\"kind\":\"dev\",\"name\":\"wasm-bindgen\",\"req\":\"^0.2.89\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"wasm-bindgen-futures\",\"req\":\"^0.4.18\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"wasm-streams\",\"optional\":true,\"req\":\"^0.4\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"features\":[\"AbortController\",\"AbortSignal\",\"Headers\",\"Request\",\"RequestInit\",\"RequestMode\",\"Response\",\"Window\",\"FormData\",\"Blob\",\"BlobPropertyBag\",\"ServiceWorkerGlobalScope\",\"RequestCredentials\",\"File\",\"ReadableStream\",\"RequestCache\"],\"name\":\"web-sys\",\"req\":\"^0.3.28\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"zstd_crate\",\"package\":\"zstd\",\"req\":\"^0.13\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"}],\"features\":{\"__rustls\":[\"dep:hyper-rustls\",\"dep:tokio-rustls\",\"dep:rustls\",\"__tls\"],\"__rustls-ring\":[\"hyper-rustls?/ring\",\"tokio-rustls?/ring\",\"rustls?/ring\",\"quinn?/ring\"],\"__tls\":[\"dep:rustls-pki-types\",\"tokio/io-util\"],\"blocking\":[\"dep:futures-channel\",\"futures-channel?/sink\",\"dep:futures-util\",\"futures-util?/io\",\"futures-util?/sink\",\"tokio/sync\"],\"brotli\":[\"tower-http/decompression-br\"],\"charset\":[\"dep:encoding_rs\",\"dep:mime\"],\"cookies\":[\"dep:cookie_crate\",\"dep:cookie_store\"],\"default\":[\"default-tls\",\"charset\",\"http2\",\"system-proxy\"],\"default-tls\":[\"dep:hyper-tls\",\"dep:native-tls-crate\",\"__tls\",\"dep:tokio-native-tls\"],\"deflate\":[\"tower-http/decompression-deflate\"],\"gzip\":[\"tower-http/decompression-gzip\"],\"hickory-dns\":[\"dep:hickory-resolver\",\"dep:once_cell\"],\"http2\":[\"h2\",\"hyper/http2\",\"hyper-util/http2\",\"hyper-rustls?/http2\"],\"http3\":[\"rustls-tls-manual-roots\",\"dep:h3\",\"dep:h3-quinn\",\"dep:quinn\",\"tokio/macros\"],\"json\":[\"dep:serde_json\"],\"macos-system-configuration\":[\"system-proxy\"],\"multipart\":[\"dep:mime_guess\",\"dep:futures-util\"],\"native-tls\":[\"default-tls\"],\"native-tls-alpn\":[\"native-tls\",\"native-tls-crate?/alpn\",\"hyper-tls?/alpn\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate?/vendored\"],\"rustls-tls\":[\"rustls-tls-webpki-roots\"],\"rustls-tls-manual-roots\":[\"rustls-tls-manual-roots-no-provider\",\"__rustls-ring\"],\"rustls-tls-manual-roots-no-provider\":[\"__rustls\"],\"rustls-tls-native-roots\":[\"rustls-tls-native-roots-no-provider\",\"__rustls-ring\"],\"rustls-tls-native-roots-no-provider\":[\"dep:rustls-native-certs\",\"hyper-rustls?/native-tokio\",\"__rustls\"],\"rustls-tls-no-provider\":[\"rustls-tls-manual-roots-no-provider\"],\"rustls-tls-webpki-roots\":[\"rustls-tls-webpki-roots-no-provider\",\"__rustls-ring\"],\"rustls-tls-webpki-roots-no-provider\":[\"dep:webpki-roots\",\"hyper-rustls?/webpki-tokio\",\"__rustls\"],\"socks\":[],\"stream\":[\"tokio/fs\",\"dep:futures-util\",\"dep:tokio-util\",\"dep:wasm-streams\"],\"system-proxy\":[\"hyper-util/client-proxy-system\"],\"trust-dns\":[],\"zstd\":[\"tower-http/decompression-zstd\"]}}", + "resb_0.1.1": "{\"dependencies\":[{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.17\"},{\"name\":\"nom\",\"optional\":true,\"req\":\"^7.0.0\"},{\"default_features\":false,\"name\":\"potential_utf\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_core\",\"req\":\"^1.0.220\"}],\"features\":{\"default\":[],\"logging\":[\"dep:log\"],\"serialize\":[\"std\"],\"std\":[],\"text\":[\"dep:indexmap\",\"dep:nom\",\"std\"]}}", "resolv-conf_0.7.6": "{\"dependencies\":[],\"features\":{\"system\":[]}}", "ring_0.17.14": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.2.8\"},{\"default_features\":false,\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"getrandom\",\"req\":\"^0.2.10\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.148\",\"target\":\"cfg(all(any(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), all(target_arch = \\\"arm\\\", target_endian = \\\"little\\\")), any(target_os = \\\"android\\\", target_os = \\\"linux\\\")))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), target_vendor = \\\"apple\\\", any(target_os = \\\"ios\\\", target_os = \\\"macos\\\", target_os = \\\"tvos\\\", target_os = \\\"visionos\\\", target_os = \\\"watchos\\\")))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.148\",\"target\":\"cfg(any(unix, windows, target_os = \\\"wasi\\\"))\"},{\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.37\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Threading\"],\"name\":\"windows-sys\",\"req\":\"^0.52\",\"target\":\"cfg(all(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), target_os = \\\"windows\\\"))\"}],\"features\":{\"alloc\":[],\"default\":[\"alloc\",\"dev_urandom_fallback\"],\"dev_urandom_fallback\":[],\"less-safe-getrandom-custom-or-rdrand\":[],\"less-safe-getrandom-espidf\":[],\"slow_tests\":[],\"std\":[\"alloc\"],\"test_logging\":[],\"unstable-testing-arm-no-hw\":[],\"unstable-testing-arm-no-neon\":[],\"wasm32_unknown_unknown_js\":[\"getrandom/js\"]}}", "rmcp-macros_0.15.0": "{\"dependencies\":[{\"name\":\"darling\",\"req\":\"^0.23\"},{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", @@ -1199,7 +1212,7 @@ "rustix_1.1.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2.4.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(all(criterion, not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.177\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.177\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.171\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(windows)\"},{\"default_features\":false,\"name\":\"libc_errno\",\"optional\":true,\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\"},{\"default_features\":false,\"features\":[\"general\",\"ioctl\",\"no_std\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.11.0\",\"target\":\"cfg(all(any(target_os = \\\"linux\\\", target_os = \\\"android\\\"), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"features\":[\"auxvec\",\"general\",\"errno\",\"ioctl\",\"no_std\",\"elf\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.11.0\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"memoffset\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.20.3\",\"target\":\"cfg(windows)\"},{\"name\":\"rustc-std-workspace-alloc\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5.0\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"all-apis\":[\"event\",\"fs\",\"io_uring\",\"mm\",\"mount\",\"net\",\"param\",\"pipe\",\"process\",\"pty\",\"rand\",\"runtime\",\"shm\",\"stdio\",\"system\",\"termios\",\"thread\",\"time\"],\"alloc\":[],\"default\":[\"std\"],\"event\":[],\"fs\":[],\"io_uring\":[\"event\",\"fs\",\"net\",\"thread\",\"linux-raw-sys/io_uring\"],\"linux_4_11\":[],\"linux_5_1\":[\"linux_4_11\"],\"linux_5_11\":[\"linux_5_1\"],\"linux_latest\":[\"linux_5_11\"],\"mm\":[],\"mount\":[],\"net\":[\"linux-raw-sys/net\",\"linux-raw-sys/netlink\",\"linux-raw-sys/if_ether\",\"linux-raw-sys/xdp\"],\"param\":[],\"pipe\":[],\"process\":[\"linux-raw-sys/prctl\"],\"pty\":[\"fs\"],\"rand\":[],\"runtime\":[\"linux-raw-sys/prctl\"],\"rustc-dep-of-std\":[\"core\",\"rustc-std-workspace-alloc\",\"linux-raw-sys/rustc-dep-of-std\",\"bitflags/rustc-dep-of-std\"],\"shm\":[\"fs\"],\"std\":[\"bitflags/std\",\"alloc\",\"libc?/std\",\"libc_errno?/std\"],\"stdio\":[],\"system\":[\"linux-raw-sys/system\"],\"termios\":[],\"thread\":[\"linux-raw-sys/prctl\"],\"time\":[],\"try_close\":[],\"use-explicitly-provided-auxv\":[],\"use-libc\":[\"libc_errno\",\"libc\"],\"use-libc-auxv\":[]}}", "rustls-native-certs_0.8.3": "{\"dependencies\":[{\"name\":\"openssl-probe\",\"req\":\"^0.2\",\"target\":\"cfg(all(unix, not(target_os = \\\"macos\\\")))\"},{\"features\":[\"std\"],\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.10\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17\"},{\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"kind\":\"dev\",\"name\":\"rustls-webpki\",\"req\":\"^0.103\"},{\"name\":\"schannel\",\"req\":\"^0.1\",\"target\":\"cfg(windows)\"},{\"name\":\"security-framework\",\"req\":\"^3\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5\"},{\"kind\":\"dev\",\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.18\"}],\"features\":{}}", "rustls-pki-types_1.14.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"crabgrind\",\"req\":\"=0.1.9\",\"target\":\"cfg(all(target_os = \\\"linux\\\", target_arch = \\\"x86_64\\\"))\"},{\"name\":\"web-time\",\"optional\":true,\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"dep:zeroize\"],\"default\":[\"alloc\"],\"std\":[\"alloc\"],\"web\":[\"web-time\"]}}", - "rustls-webpki_0.103.9": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.14\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"kind\":\"dev\",\"name\":\"bzip2\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.17.2\"},{\"default_features\":false,\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.12\"},{\"default_features\":false,\"features\":[\"aws_lc_rs\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14.2\"},{\"default_features\":false,\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"untrusted\",\"req\":\"^0.9\"}],\"features\":{\"alloc\":[\"ring?/alloc\",\"pki-types/alloc\"],\"aws-lc-rs\":[\"dep:aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\",\"aws-lc-rs/prebuilt-nasm\"],\"aws-lc-rs-fips\":[\"dep:aws-lc-rs\",\"aws-lc-rs/fips\"],\"aws-lc-rs-unstable\":[\"aws-lc-rs\",\"aws-lc-rs/unstable\"],\"default\":[\"std\"],\"ring\":[\"dep:ring\"],\"std\":[\"alloc\",\"pki-types/std\"]}}", + "rustls-webpki_0.103.10": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.14\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"kind\":\"dev\",\"name\":\"bzip2\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.17.2\"},{\"default_features\":false,\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.12\"},{\"default_features\":false,\"features\":[\"aws_lc_rs\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14.2\"},{\"default_features\":false,\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.18.1\"}],\"features\":{\"alloc\":[\"ring?/alloc\",\"pki-types/alloc\"],\"aws-lc-rs\":[\"dep:aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\",\"aws-lc-rs/prebuilt-nasm\"],\"aws-lc-rs-fips\":[\"dep:aws-lc-rs\",\"aws-lc-rs/fips\"],\"aws-lc-rs-unstable\":[\"aws-lc-rs\",\"aws-lc-rs/unstable\"],\"default\":[\"std\"],\"ring\":[\"dep:ring\"],\"std\":[\"alloc\",\"pki-types/std\"]}}", "rustls_0.23.36": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.14\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"brotli\",\"optional\":true,\"req\":\"^8\"},{\"name\":\"brotli-decompressor\",\"optional\":true,\"req\":\"^5.0.0\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"default-hasher\",\"inline-more\"],\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.8\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.8\"},{\"kind\":\"dev\",\"name\":\"macro_rules_attribute\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4.4\"},{\"default_features\":false,\"features\":[\"alloc\",\"race\"],\"name\":\"once_cell\",\"req\":\"^1.16\"},{\"features\":[\"alloc\"],\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.12\"},{\"default_features\":false,\"features\":[\"pem\",\"aws_lc_rs\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"kind\":\"build\",\"name\":\"rustversion\",\"optional\":true,\"req\":\"^1.0.6\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.5.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.6\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103.5\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.17\"},{\"name\":\"zeroize\",\"req\":\"^1.8\"},{\"name\":\"zlib-rs\",\"optional\":true,\"req\":\"^0.5\"}],\"features\":{\"aws-lc-rs\":[\"aws_lc_rs\"],\"aws_lc_rs\":[\"dep:aws-lc-rs\",\"webpki/aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\",\"aws-lc-rs/prebuilt-nasm\"],\"brotli\":[\"dep:brotli\",\"dep:brotli-decompressor\",\"std\"],\"custom-provider\":[],\"default\":[\"aws_lc_rs\",\"logging\",\"prefer-post-quantum\",\"std\",\"tls12\"],\"fips\":[\"aws_lc_rs\",\"aws-lc-rs?/fips\",\"webpki/aws-lc-rs-fips\"],\"logging\":[\"log\"],\"prefer-post-quantum\":[\"aws_lc_rs\"],\"read_buf\":[\"rustversion\",\"std\"],\"ring\":[\"dep:ring\",\"webpki/ring\"],\"std\":[\"webpki/std\",\"pki-types/std\",\"once_cell/std\"],\"tls12\":[],\"zlib\":[\"dep:zlib-rs\"]}}", "rustversion_1.0.22": "{\"dependencies\":[{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.49\"}],\"features\":{}}", "rustyline_14.0.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.2\"},{\"name\":\"bitflags\",\"req\":\"^2.0\"},{\"default_features\":false,\"name\":\"buffer-redux\",\"optional\":true,\"req\":\"^1.0\",\"target\":\"cfg(unix)\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"clipboard-win\",\"req\":\"^5.0\",\"target\":\"cfg(windows)\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"name\":\"fd-lock\",\"optional\":true,\"req\":\"^4.0.0\"},{\"name\":\"home\",\"optional\":true,\"req\":\"^0.5.4\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"memchr\",\"req\":\"^2.0\"},{\"default_features\":false,\"features\":[\"fs\",\"ioctl\",\"poll\",\"signal\",\"term\"],\"name\":\"nix\",\"req\":\"^0.28\",\"target\":\"cfg(unix)\"},{\"name\":\"radix_trie\",\"optional\":true,\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1.5.5\"},{\"default_features\":false,\"features\":[\"bundled\",\"backup\"],\"name\":\"rusqlite\",\"optional\":true,\"req\":\"^0.31.0\"},{\"name\":\"rustyline-derive\",\"optional\":true,\"req\":\"^0.10.0\"},{\"default_features\":false,\"name\":\"signal-hook\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(unix)\"},{\"default_features\":false,\"name\":\"skim\",\"optional\":true,\"req\":\"^0.10\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\"},{\"name\":\"termios\",\"optional\":true,\"req\":\"^0.3.3\",\"target\":\"cfg(unix)\"},{\"name\":\"unicode-segmentation\",\"req\":\"^1.0\"},{\"name\":\"unicode-width\",\"req\":\"^0.1\"},{\"name\":\"utf8parse\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Console\",\"Win32_Security\",\"Win32_System_Threading\",\"Win32_UI_Input_KeyboardAndMouse\"],\"name\":\"windows-sys\",\"req\":\"^0.52.0\",\"target\":\"cfg(windows)\"}],\"features\":{\"case_insensitive_history_search\":[\"regex\"],\"custom-bindings\":[\"radix_trie\"],\"default\":[\"custom-bindings\",\"with-dirs\",\"with-file-history\"],\"derive\":[\"rustyline-derive\"],\"with-dirs\":[\"home\"],\"with-file-history\":[\"fd-lock\"],\"with-fuzzy\":[\"skim\"],\"with-sqlite-history\":[\"rusqlite\"]}}", @@ -1292,6 +1305,7 @@ "starlark_syntax_0.13.0": "{\"dependencies\":[{\"name\":\"allocative\",\"req\":\"^0.3.4\"},{\"name\":\"annotate-snippets\",\"req\":\"^0.9.0\"},{\"name\":\"anyhow\",\"req\":\"^1.0.65\"},{\"name\":\"derivative\",\"req\":\"^2.2\"},{\"features\":[\"full\"],\"name\":\"derive_more\",\"req\":\"^1.0.0\"},{\"name\":\"dupe\",\"req\":\"^0.9.0\"},{\"kind\":\"build\",\"name\":\"lalrpop\",\"req\":\"^0.19.7\"},{\"name\":\"lalrpop-util\",\"req\":\"^0.19.7\"},{\"name\":\"logos\",\"req\":\"^0.12\"},{\"name\":\"lsp-types\",\"req\":\"^0.94.1\"},{\"name\":\"memchr\",\"req\":\"^2.4.1\"},{\"name\":\"num-bigint\",\"req\":\"^0.4.3\"},{\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"name\":\"once_cell\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"starlark_map\",\"req\":\"^0.13.0\"},{\"name\":\"thiserror\",\"req\":\"^1.0.36\"}],\"features\":{}}", "static_assertions_1.1.0": "{\"dependencies\":[],\"features\":{\"nightly\":[]}}", "stop-words_0.9.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"human_regex\",\"req\":\"^0.3.0\"},{\"kind\":\"build\",\"name\":\"serde_json\",\"req\":\"^1\"}],\"features\":{\"constructed\":[],\"default\":[\"iso\"],\"iso\":[],\"nltk\":[],\"unimplemented\":[]}}", + "strck_1.0.0": "{\"dependencies\":[{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"smol_str\",\"req\":\"^0.3\"},{\"name\":\"unicode-ident\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"ident\":[\"dep:unicode-ident\"]}}", "streaming-iterator_0.1.9": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"std\":[\"alloc\"]}}", "string_cache_0.8.9": "{\"dependencies\":[{\"default_features\":false,\"name\":\"malloc_size_of\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"new_debug_unreachable\",\"req\":\"^1.0.2\"},{\"name\":\"parking_lot\",\"req\":\"^0.12\"},{\"name\":\"phf_shared\",\"req\":\"^0.11\"},{\"name\":\"precomputed-hash\",\"req\":\"^0.1\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"default\":[\"serde_support\"],\"serde_support\":[\"serde\"]}}", "stringprep_0.1.5": "{\"dependencies\":[{\"name\":\"unicode-bidi\",\"req\":\"^0.3\"},{\"name\":\"unicode-normalization\",\"req\":\"^0.1\"},{\"name\":\"unicode-properties\",\"req\":\"^0.1.1\"}],\"features\":{}}", @@ -1314,8 +1328,10 @@ "system-configuration-sys_0.6.0": "{\"dependencies\":[{\"name\":\"core-foundation-sys\",\"req\":\"^0.8\"},{\"name\":\"libc\",\"req\":\"^0.2.149\"}],\"features\":{}}", "system-configuration_0.6.1": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2\"},{\"name\":\"core-foundation\",\"req\":\"^0.9\"},{\"name\":\"system-configuration-sys\",\"req\":\"^0.6\"}],\"features\":{}}", "tagptr_0.2.0": "{\"dependencies\":[],\"features\":{}}", - "tar_0.4.44": "{\"dependencies\":[{\"name\":\"filetime\",\"req\":\"^0.2.8\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"},{\"name\":\"xattr\",\"optional\":true,\"req\":\"^1.1.3\",\"target\":\"cfg(unix)\"}],\"features\":{\"default\":[\"xattr\"]}}", + "tar_0.4.45": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"astral-tokio-tar\",\"req\":\"^0.5\"},{\"name\":\"filetime\",\"req\":\"^0.2.8\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"},{\"features\":[\"macros\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"name\":\"xattr\",\"optional\":true,\"req\":\"^1.1.3\",\"target\":\"cfg(unix)\"}],\"features\":{\"default\":[\"xattr\"]}}", "tempfile_3.24.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"name\":\"fastrand\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(any(unix, windows, target_os = \\\"wasi\\\"))\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"once_cell\",\"req\":\"^1.19.0\"},{\"features\":[\"fs\"],\"name\":\"rustix\",\"req\":\"^1.1.3\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\"))\"},{\"features\":[\"Win32_Storage_FileSystem\",\"Win32_Foundation\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"getrandom\"],\"nightly\":[]}}", + "temporal_capi_0.1.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"diplomat\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"name\":\"diplomat-runtime\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"features\":[\"unstable\"],\"name\":\"icu_calendar\",\"req\":\"^2.1.0\"},{\"name\":\"icu_locale\",\"req\":\"^2.1.0\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2.19\"},{\"default_features\":false,\"name\":\"temporal_rs\",\"req\":\"^0.1.2\"},{\"name\":\"timezone_provider\",\"req\":\"^0.1.2\"},{\"name\":\"writeable\",\"req\":\"^0.6.0\"},{\"name\":\"zoneinfo64\",\"optional\":true,\"req\":\"^0.2.0\"}],\"features\":{\"compiled_data\":[\"temporal_rs/compiled_data\"],\"zoneinfo64\":[\"dep:zoneinfo64\",\"timezone_provider/zoneinfo64\"]}}", + "temporal_rs_0.1.2": "{\"dependencies\":[{\"name\":\"core_maths\",\"req\":\"^0.1.1\"},{\"name\":\"iana-time-zone\",\"optional\":true,\"req\":\"^0.1.64\"},{\"default_features\":false,\"features\":[\"unstable\",\"compiled_data\"],\"name\":\"icu_calendar\",\"req\":\"^2.1.0\"},{\"name\":\"icu_locale\",\"req\":\"^2.1.0\"},{\"features\":[\"duration\"],\"name\":\"ixdtf\",\"req\":\"^0.6.4\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.28\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2.19\"},{\"name\":\"timezone_provider\",\"req\":\"^0.1.2\"},{\"name\":\"tinystr\",\"req\":\"^0.8.0\"},{\"name\":\"web-time\",\"optional\":true,\"req\":\"^1.1.0\"},{\"name\":\"writeable\",\"req\":\"^0.6.0\"}],\"features\":{\"compiled_data\":[\"tzdb\"],\"default\":[\"sys\"],\"float64_representable_durations\":[],\"log\":[\"dep:log\"],\"std\":[],\"sys\":[\"std\",\"compiled_data\",\"dep:web-time\",\"dep:iana-time-zone\"],\"tzdb\":[\"std\",\"timezone_provider/tzif\"]}}", "term_0.7.0": "{\"dependencies\":[{\"name\":\"dirs-next\",\"req\":\"^2\"},{\"name\":\"rustversion\",\"req\":\"^1\",\"target\":\"cfg(windows)\"},{\"features\":[\"consoleapi\",\"wincon\",\"handleapi\",\"fileapi\"],\"name\":\"winapi\",\"req\":\"^0.3\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[]}}", "termcolor_1.4.1": "{\"dependencies\":[{\"name\":\"winapi-util\",\"req\":\"^0.1.3\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "terminal_size_0.4.3": "{\"dependencies\":[{\"features\":[\"termios\"],\"name\":\"rustix\",\"req\":\"^1.0.1\",\"target\":\"cfg(unix)\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Console\"],\"name\":\"windows-sys\",\"req\":\"^0.60.0\",\"target\":\"cfg(windows)\"}],\"features\":{}}", @@ -1336,6 +1352,7 @@ "time-core_0.1.8": "{\"dependencies\":[],\"features\":{\"large-dates\":[]}}", "time-macros_0.2.27": "{\"dependencies\":[{\"name\":\"num-conv\",\"req\":\"^0.2.0\"},{\"name\":\"time-core\",\"req\":\"=0.1.8\"}],\"features\":{\"formatting\":[],\"large-dates\":[],\"parsing\":[],\"serde\":[]}}", "time_0.3.47": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8.1\",\"target\":\"cfg(bench)\"},{\"features\":[\"powerfmt\"],\"name\":\"deranged\",\"req\":\"^0.5.2\"},{\"name\":\"itoa\",\"optional\":true,\"req\":\"^1.0.1\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3.58\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.98\",\"target\":\"cfg(target_family = \\\"unix\\\")\"},{\"name\":\"num-conv\",\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"num-conv\",\"req\":\"^0.2.0\"},{\"name\":\"num_threads\",\"optional\":true,\"req\":\"^0.1.2\",\"target\":\"cfg(target_family = \\\"unix\\\")\"},{\"default_features\":false,\"name\":\"powerfmt\",\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"rand08\",\"optional\":true,\"package\":\"rand\",\"req\":\"^0.8.4\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"rand08\",\"package\":\"rand\",\"req\":\"^0.8.4\"},{\"default_features\":false,\"name\":\"rand09\",\"optional\":true,\"package\":\"rand\",\"req\":\"^0.9.2\"},{\"default_features\":false,\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand09\",\"package\":\"rand\",\"req\":\"^0.9.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.26.1\"},{\"kind\":\"dev\",\"name\":\"rstest_reuse\",\"req\":\"^0.7.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.184\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.68\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.126\"},{\"name\":\"time-core\",\"req\":\"=0.1.8\"},{\"name\":\"time-macros\",\"optional\":true,\"req\":\"=0.2.27\"},{\"kind\":\"dev\",\"name\":\"time-macros\",\"req\":\"=0.2.27\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.102\",\"target\":\"cfg(__ui_tests)\"}],\"features\":{\"alloc\":[\"serde_core?/alloc\"],\"default\":[\"std\"],\"formatting\":[\"dep:itoa\",\"std\",\"time-macros?/formatting\"],\"large-dates\":[\"time-core/large-dates\",\"time-macros?/large-dates\"],\"local-offset\":[\"std\",\"dep:libc\",\"dep:num_threads\"],\"macros\":[\"dep:time-macros\"],\"parsing\":[\"time-macros?/parsing\"],\"quickcheck\":[\"dep:quickcheck\",\"alloc\",\"deranged/quickcheck\"],\"rand\":[\"rand08\",\"rand09\"],\"rand08\":[\"dep:rand08\",\"deranged/rand08\"],\"rand09\":[\"dep:rand09\",\"deranged/rand09\"],\"serde\":[\"dep:serde_core\",\"time-macros?/serde\",\"deranged/serde\"],\"serde-human-readable\":[\"serde\",\"formatting\",\"parsing\"],\"serde-well-known\":[\"serde\",\"formatting\",\"parsing\"],\"std\":[\"alloc\"],\"wasm-bindgen\":[\"dep:js-sys\"]}}", + "timezone_provider_0.1.2": "{\"dependencies\":[{\"name\":\"combine\",\"optional\":true,\"req\":\"^4.6.7\"},{\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"name\":\"jiff-tzdb\",\"optional\":true,\"req\":\"^0.1.4\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.225\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.145\"},{\"features\":[\"zerovec\"],\"name\":\"tinystr\",\"req\":\"^0.8.0\"},{\"name\":\"tzif\",\"optional\":true,\"req\":\"^0.4.0\"},{\"features\":[\"derive\"],\"name\":\"yoke\",\"optional\":true,\"req\":\"^0.8.0\"},{\"name\":\"zerotrie\",\"req\":\"^0.2.0\"},{\"features\":[\"derive\",\"alloc\"],\"name\":\"zerovec\",\"req\":\"^0.11.0\"},{\"name\":\"zoneinfo64\",\"optional\":true,\"req\":\"^0.2.0\"},{\"features\":[\"std\"],\"name\":\"zoneinfo_rs\",\"optional\":true,\"req\":\"^0.0.18\"}],\"features\":{\"datagen\":[\"std\",\"dep:serde\",\"dep:databake\",\"dep:yoke\",\"dep:serde_json\",\"tinystr/serde\",\"tinystr/databake\",\"zerotrie/serde\",\"zerotrie/databake\",\"zerovec/serde\",\"zerovec/databake\",\"zerovec/derive\",\"dep:zoneinfo_rs\",\"experimental_tzif\"],\"default\":[],\"experimental_tzif\":[],\"std\":[],\"tzif\":[\"dep:tzif\",\"dep:jiff-tzdb\",\"dep:combine\",\"std\"],\"zoneinfo64\":[\"dep:zoneinfo64\"]}}", "tiny-keccak_2.0.2": "{\"dependencies\":[{\"name\":\"crunchy\",\"req\":\"^0.2.2\"}],\"features\":{\"cshake\":[],\"default\":[],\"fips202\":[\"keccak\",\"shake\",\"sha3\"],\"k12\":[],\"keccak\":[],\"kmac\":[\"cshake\"],\"parallel_hash\":[\"cshake\"],\"sha3\":[],\"shake\":[],\"sp800\":[\"cshake\",\"kmac\",\"tuple_hash\"],\"tuple_hash\":[\"cshake\"]}}", "tiny_http_0.12.0": "{\"dependencies\":[{\"name\":\"ascii\",\"req\":\"^1.0\"},{\"name\":\"chunked_transfer\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"fdlimit\",\"req\":\"^0.1\"},{\"name\":\"httpdate\",\"req\":\"^1.0.2\"},{\"name\":\"log\",\"req\":\"^0.4.4\"},{\"name\":\"openssl\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"rustc-serialize\",\"req\":\"^0.3\"},{\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.20\"},{\"name\":\"rustls-pemfile\",\"optional\":true,\"req\":\"^0.2.1\"},{\"kind\":\"dev\",\"name\":\"sha1\",\"req\":\"^0.6.0\"},{\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"default\":[],\"ssl\":[\"ssl-openssl\"],\"ssl-openssl\":[\"openssl\",\"zeroize\"],\"ssl-rustls\":[\"rustls\",\"rustls-pemfile\",\"zeroize\"]}}", "tinystr_0.8.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"default_features\":false,\"features\":[\"use-std\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"name\":\"zerovec\",\"optional\":true,\"req\":\"^0.11.3\"}],\"features\":{\"alloc\":[\"serde_core?/alloc\",\"zerovec?/alloc\"],\"databake\":[\"dep:databake\"],\"default\":[\"alloc\"],\"serde\":[\"dep:serde_core\"],\"std\":[],\"zerovec\":[\"dep:zerovec\"]}}", @@ -1411,6 +1428,7 @@ "utf8_iter_1.0.4": "{\"dependencies\":[],\"features\":{}}", "utf8parse_0.2.2": "{\"dependencies\":[],\"features\":{\"default\":[],\"nightly\":[]}}", "uuid_1.20.0": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.1.3\"},{\"default_features\":false,\"name\":\"atomic\",\"optional\":true,\"req\":\"^0.6\"},{\"default_features\":false,\"name\":\"borsh\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"borsh-derive\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"derive\"],\"name\":\"bytemuck\",\"optional\":true,\"req\":\"^1.20.0\"},{\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"), target_feature = \\\"atomics\\\"))\"},{\"default_features\":false,\"name\":\"md-5\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"rand\",\"optional\":true,\"req\":\"^0.9\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.221\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.221\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.221\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.56\"},{\"default_features\":false,\"name\":\"sha1_smol\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"slog\",\"optional\":true,\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.52\"},{\"name\":\"uuid-rng-internal-lib\",\"optional\":true,\"package\":\"uuid-rng-internal\",\"req\":\"^1.20.0\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"default_features\":false,\"features\":[\"msrv\"],\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen\",\"req\":\"^0.2\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"features\":[\"derive\"],\"name\":\"zerocopy\",\"optional\":true,\"req\":\"^0.8\"}],\"features\":{\"atomic\":[\"dep:atomic\"],\"borsh\":[\"dep:borsh\",\"dep:borsh-derive\"],\"default\":[\"std\"],\"fast-rng\":[\"rng\",\"dep:rand\"],\"js\":[\"dep:wasm-bindgen\",\"dep:js-sys\"],\"macro-diagnostics\":[],\"md5\":[\"dep:md-5\"],\"rng\":[\"dep:getrandom\"],\"rng-getrandom\":[\"rng\",\"dep:getrandom\",\"uuid-rng-internal-lib\",\"uuid-rng-internal-lib/getrandom\"],\"rng-rand\":[\"rng\",\"dep:rand\",\"uuid-rng-internal-lib\",\"uuid-rng-internal-lib/rand\"],\"serde\":[\"dep:serde_core\"],\"sha1\":[\"dep:sha1_smol\"],\"std\":[\"wasm-bindgen?/std\",\"js-sys?/std\"],\"v1\":[\"atomic\"],\"v3\":[\"md5\"],\"v4\":[\"rng\"],\"v5\":[\"sha1\"],\"v6\":[\"atomic\"],\"v7\":[\"rng\"],\"v8\":[]}}", + "v8_146.4.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"align-data\",\"req\":\"^0.1.0\"},{\"kind\":\"build\",\"name\":\"bindgen\",\"req\":\"^0.72\"},{\"kind\":\"dev\",\"name\":\"bindgen\",\"req\":\"^0.72\"},{\"name\":\"bitflags\",\"req\":\"^2.5\"},{\"kind\":\"dev\",\"name\":\"bytes\",\"req\":\"^1\"},{\"kind\":\"build\",\"name\":\"fslock\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"fslock\",\"req\":\"^0.2\"},{\"kind\":\"build\",\"name\":\"gzip-header\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"gzip-header\",\"req\":\"^1.0.0\"},{\"kind\":\"build\",\"name\":\"home\",\"req\":\"^0\"},{\"kind\":\"dev\",\"name\":\"home\",\"req\":\"^0\"},{\"kind\":\"build\",\"name\":\"miniz_oxide\",\"req\":\"^0.8.8\"},{\"kind\":\"dev\",\"name\":\"miniz_oxide\",\"req\":\"^0.8.8\"},{\"name\":\"paste\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"features\":[\"zoneinfo64\"],\"name\":\"temporal_capi\",\"req\":\"^0.1.2\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.96\"},{\"kind\":\"build\",\"name\":\"which\",\"req\":\"^6\"},{\"kind\":\"dev\",\"name\":\"which\",\"req\":\"^6\"}],\"features\":{\"default\":[\"use_custom_libcxx\"],\"use_custom_libcxx\":[],\"v8_enable_pointer_compression\":[],\"v8_enable_sandbox\":[\"v8_enable_pointer_compression\"],\"v8_enable_v8_checks\":[]}}", "valuable_0.1.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"name\":\"valuable-derive\",\"optional\":true,\"req\":\"=0.1.1\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"derive\":[\"valuable-derive\"],\"std\":[\"alloc\"]}}", "vcpkg_0.2.15": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tempdir\",\"req\":\"^0.3.7\"}],\"features\":{}}", "version_check_0.9.5": "{\"dependencies\":[],\"features\":{}}", @@ -1441,6 +1459,7 @@ "webpki-roots_0.26.11": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"parent\",\"package\":\"webpki-roots\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"percent-encoding\",\"req\":\"^2.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.13\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17.0\"},{\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.102\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.17.0\"},{\"kind\":\"dev\",\"name\":\"yasna\",\"req\":\"^0.5.2\"}],\"features\":{}}", "webpki-roots_1.0.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"percent-encoding\",\"req\":\"^2.3\"},{\"default_features\":false,\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14.3\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17.0\"},{\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.17.0\"},{\"kind\":\"dev\",\"name\":\"yasna\",\"req\":\"^0.5.2\"}],\"features\":{}}", "weezl_0.1.12": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures\",\"optional\":true,\"req\":\"^0.3.12\"},{\"default_features\":false,\"features\":[\"macros\",\"io-util\",\"net\",\"rt\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"compat\"],\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.6.2\"}],\"features\":{\"alloc\":[],\"async\":[\"futures\",\"std\"],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", + "which_6.0.3": "{\"dependencies\":[{\"name\":\"either\",\"req\":\"^1.9.0\"},{\"name\":\"home\",\"req\":\"^0.5.9\",\"target\":\"cfg(any(windows, unix, target_os = \\\"redox\\\"))\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1.10.2\"},{\"default_features\":false,\"features\":[\"fs\",\"std\"],\"name\":\"rustix\",\"req\":\"^0.38.30\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\", target_os = \\\"redox\\\"))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.9.0\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.40\"},{\"features\":[\"kernel\"],\"name\":\"winsafe\",\"req\":\"^0.0.19\",\"target\":\"cfg(windows)\"}],\"features\":{\"regex\":[\"dep:regex\"],\"tracing\":[\"dep:tracing\"]}}", "which_8.0.0": "{\"dependencies\":[{\"name\":\"env_home\",\"optional\":true,\"req\":\"^0.1.0\",\"target\":\"cfg(any(windows, unix, target_os = \\\"redox\\\"))\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1.10.2\"},{\"default_features\":false,\"features\":[\"fs\",\"std\"],\"name\":\"rustix\",\"optional\":true,\"req\":\"^1.0.5\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\", target_os = \\\"redox\\\"))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.9.0\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.40\"},{\"features\":[\"kernel\"],\"name\":\"winsafe\",\"optional\":true,\"req\":\"^0.0.19\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"real-sys\"],\"real-sys\":[\"dep:env_home\",\"dep:rustix\",\"dep:winsafe\"],\"regex\":[\"dep:regex\"],\"tracing\":[\"dep:tracing\"]}}", "whoami_1.6.1": "{\"dependencies\":[{\"name\":\"libredox\",\"req\":\"^0.1.1\",\"target\":\"cfg(all(target_os = \\\"redox\\\", not(target_arch = \\\"wasm32\\\")))\"},{\"name\":\"wasite\",\"req\":\"^0.1\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"wasi\\\"))\"},{\"features\":[\"Navigator\",\"Document\",\"Window\",\"Location\"],\"name\":\"web-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(target_os = \\\"wasi\\\"), not(daku)))\"}],\"features\":{\"default\":[\"web\"],\"web\":[\"web-sys\"]}}", "widestring_1.2.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"debugger_test\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"debugger_test_parser\",\"req\":\"^0.1\"},{\"features\":[\"Win32_System_Diagnostics_Debug\"],\"kind\":\"dev\",\"name\":\"windows-sys\",\"req\":\"^0.59\"}],\"features\":{\"alloc\":[],\"debugger_visualizer\":[\"alloc\"],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", @@ -1547,6 +1566,7 @@ "zerovec_0.11.5": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"xxhash64\"],\"name\":\"twox-hash\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"name\":\"yoke\",\"optional\":true,\"req\":\"^0.8.0\"},{\"default_features\":false,\"name\":\"zerofrom\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"name\":\"zerovec-derive\",\"optional\":true,\"req\":\"^0.11.1\"}],\"features\":{\"alloc\":[\"serde?/alloc\"],\"databake\":[\"dep:databake\"],\"derive\":[\"dep:zerovec-derive\"],\"hashmap\":[\"dep:twox-hash\",\"alloc\"],\"serde\":[\"dep:serde\"],\"std\":[],\"yoke\":[\"dep:yoke\"]}}", "zip_2.4.2": "{\"dependencies\":[{\"name\":\"aes\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.95\"},{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"req\":\"^1.4.1\",\"target\":\"cfg(fuzzing)\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"bzip2\",\"optional\":true,\"req\":\"^0.5.0\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"=4.4.18\"},{\"name\":\"constant_time_eq\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"crc32fast\",\"req\":\"^1.4\"},{\"name\":\"crossbeam-utils\",\"req\":\"^0.8.21\",\"target\":\"cfg(any(all(target_arch = \\\"arm\\\", target_pointer_width = \\\"32\\\"), target_arch = \\\"mips\\\", target_arch = \\\"powerpc\\\"))\"},{\"name\":\"deflate64\",\"optional\":true,\"req\":\"^0.1.9\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"flate2\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"wasm_js\",\"std\"],\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.3.1\"},{\"features\":[\"wasm_js\",\"std\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.3.1\"},{\"features\":[\"reset\"],\"name\":\"hmac\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"indexmap\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"lzma-rs\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"memchr\",\"req\":\"^2.7\"},{\"default_features\":false,\"name\":\"nt-time\",\"optional\":true,\"req\":\"^0.10.6\"},{\"name\":\"pbkdf2\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.15\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.37\"},{\"default_features\":false,\"features\":[\"formatting\",\"macros\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.37\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.5\"},{\"name\":\"xz2\",\"optional\":true,\"req\":\"^0.1.7\"},{\"features\":[\"zeroize_derive\"],\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.8\"},{\"name\":\"zopfli\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"zstd\",\"optional\":true,\"req\":\"^0.13\"}],\"features\":{\"_all-features\":[],\"_deflate-any\":[],\"aes-crypto\":[\"aes\",\"constant_time_eq\",\"hmac\",\"pbkdf2\",\"sha1\",\"getrandom\",\"zeroize\"],\"chrono\":[\"chrono/default\"],\"default\":[\"aes-crypto\",\"bzip2\",\"deflate64\",\"deflate\",\"lzma\",\"time\",\"zstd\",\"xz\"],\"deflate\":[\"flate2/rust_backend\",\"deflate-zopfli\",\"deflate-flate2\"],\"deflate-flate2\":[\"_deflate-any\"],\"deflate-miniz\":[\"deflate\",\"deflate-flate2\"],\"deflate-zlib\":[\"flate2/zlib\",\"deflate-flate2\"],\"deflate-zlib-ng\":[\"flate2/zlib-ng\",\"deflate-flate2\"],\"deflate-zopfli\":[\"zopfli\",\"_deflate-any\"],\"lzma\":[\"lzma-rs/stream\"],\"nt-time\":[\"dep:nt-time\"],\"unreserved\":[],\"xz\":[\"dep:xz2\"]}}", "zmij_1.0.19": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\",\"target\":\"cfg(not(miri))\"},{\"name\":\"no-panic\",\"optional\":true,\"req\":\"^0.1.36\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"num-integer\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"opt-level\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"ryu\",\"req\":\"^1\"}],\"features\":{}}", + "zoneinfo64_0.2.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"calendrical_calculations\",\"req\":\"^0.2.3\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"chrono-tz\",\"req\":\"^0.10.4\"},{\"default_features\":false,\"name\":\"icu_locale_core\",\"req\":\"^2.1.0\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"features\":[\"tzdb-bundle-always\",\"std\"],\"kind\":\"dev\",\"name\":\"jiff\",\"req\":\"^0.2.15\"},{\"default_features\":false,\"name\":\"potential_utf\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"name\":\"resb\",\"req\":\"^0.1.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.220\"}],\"features\":{\"chrono\":[\"dep:chrono\"]}}", "zopfli_0.8.3": "{\"dependencies\":[{\"name\":\"bumpalo\",\"req\":\"^3.19.0\"},{\"default_features\":false,\"name\":\"crc32fast\",\"optional\":true,\"req\":\"^1.5.0\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.28\"},{\"kind\":\"dev\",\"name\":\"miniz_oxide\",\"req\":\"^0.8.9\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.7.0\"},{\"kind\":\"dev\",\"name\":\"proptest-derive\",\"req\":\"^0.6.0\"},{\"default_features\":false,\"name\":\"simd-adler32\",\"optional\":true,\"req\":\"^0.3.7\"}],\"features\":{\"default\":[\"gzip\",\"std\",\"zlib\"],\"gzip\":[\"dep:crc32fast\"],\"nightly\":[\"crc32fast?/nightly\"],\"std\":[\"crc32fast?/std\",\"dep:log\",\"simd-adler32?/std\"],\"zlib\":[\"dep:simd-adler32\"]}}", "zstd-safe_7.2.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"zstd-sys\",\"req\":\"^2.0.15\"}],\"features\":{\"arrays\":[],\"bindgen\":[\"zstd-sys/bindgen\"],\"debug\":[\"zstd-sys/debug\"],\"default\":[\"legacy\",\"arrays\",\"zdict_builder\"],\"doc-cfg\":[],\"experimental\":[\"zstd-sys/experimental\"],\"fat-lto\":[\"zstd-sys/fat-lto\"],\"legacy\":[\"zstd-sys/legacy\"],\"no_asm\":[\"zstd-sys/no_asm\"],\"pkg-config\":[\"zstd-sys/pkg-config\"],\"seekable\":[\"zstd-sys/seekable\"],\"std\":[\"zstd-sys/std\"],\"thin\":[\"zstd-sys/thin\"],\"thin-lto\":[\"zstd-sys/thin-lto\"],\"zdict_builder\":[\"zstd-sys/zdict_builder\"],\"zstdmt\":[\"zstd-sys/zstdmt\"]}}", "zstd-sys_2.0.16+zstd.1.5.7": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"runtime\"],\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.72\"},{\"features\":[\"parallel\"],\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.45\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.28\"}],\"features\":{\"debug\":[],\"default\":[\"legacy\",\"zdict_builder\",\"bindgen\"],\"experimental\":[],\"fat-lto\":[],\"legacy\":[],\"no_asm\":[],\"no_wasm_shim\":[],\"non-cargo\":[],\"pkg-config\":[],\"seekable\":[],\"std\":[],\"thin\":[],\"thin-lto\":[],\"zdict_builder\":[],\"zstdmt\":[]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 13e6eaf597..778dee3ea7 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -380,7 +380,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -391,7 +391,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -800,9 +800,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.15.4" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -811,9 +811,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.37.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a" +checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" dependencies = [ "cc", "cmake", @@ -949,6 +949,8 @@ dependencies = [ "cexpr", "clang-sys", "itertools 0.13.0", + "log", + "prettyplease", "proc-macro2", "quote", "regex", @@ -1152,6 +1154,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" +[[package]] +name = "calendrical_calculations" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0b39595c6ee54a8d0900204ba4c401d0ab4eb45adaf07178e8d017541529e7" +dependencies = [ + "core_maths", + "displaydoc", +] + [[package]] name = "cassowary" version = "0.3.0" @@ -1584,7 +1596,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "url", - "which", + "which 8.0.0", "wiremock", "zip", ] @@ -1788,6 +1800,20 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "codex-code-mode" +version = "0.0.0" +dependencies = [ + "async-trait", + "pretty_assertions", + "serde", + "serde_json", + "tokio", + "tokio-util", + "tracing", + "v8", +] + [[package]] name = "codex-config" version = "0.0.0" @@ -1845,6 +1871,7 @@ dependencies = [ "codex-arg0", "codex-artifacts", "codex-async-utils", + "codex-code-mode", "codex-config", "codex-connectors", "codex-exec-server", @@ -1930,7 +1957,7 @@ dependencies = [ "url", "uuid", "walkdir", - "which", + "which 8.0.0", "wildmatch", "windows-sys 0.52.0", "wiremock", @@ -2179,7 +2206,7 @@ dependencies = [ "serde_json", "tokio", "tracing", - "which", + "which 8.0.0", "wiremock", ] @@ -2432,7 +2459,7 @@ dependencies = [ "tracing", "urlencoding", "webbrowser", - "which", + "which 8.0.0", ] [[package]] @@ -2472,7 +2499,7 @@ dependencies = [ "tree-sitter", "tree-sitter-bash", "url", - "which", + "which 8.0.0", ] [[package]] @@ -2644,7 +2671,7 @@ dependencies = [ "uuid", "vt100", "webbrowser", - "which", + "which 8.0.0", "windows-sys 0.52.0", "winsplit", ] @@ -2736,7 +2763,7 @@ dependencies = [ "uuid", "vt100", "webbrowser", - "which", + "which 8.0.0", "windows-sys 0.52.0", "winsplit", ] @@ -2907,6 +2934,14 @@ dependencies = [ "regex-lite", ] +[[package]] +name = "codex-v8-poc" +version = "0.0.0" +dependencies = [ + "pretty_assertions", + "v8", +] + [[package]] name = "codex-windows-sandbox" version = "0.0.0" @@ -3112,6 +3147,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + [[package]] name = "core_test_support" version = "0.0.0" @@ -3121,6 +3165,7 @@ dependencies = [ "base64 0.22.1", "codex-arg0", "codex-core", + "codex-exec-server", "codex-features", "codex-protocol", "codex-utils-absolute-path", @@ -3716,6 +3761,38 @@ dependencies = [ "subtle", ] +[[package]] +name = "diplomat" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9adb46b05e2f53dcf6a7dfc242e4ce9eb60c369b6b6eb10826a01e93167f59c6" +dependencies = [ + "diplomat_core", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "diplomat-runtime" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0569bd3caaf13829da7ee4e83dbf9197a0e1ecd72772da6d08f0b4c9285c8d29" + +[[package]] +name = "diplomat_core" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51731530ed7f2d4495019abc7df3744f53338e69e2863a6a64ae91821c763df1" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "smallvec", + "strck", + "syn 2.0.114", +] + [[package]] name = "dirs" version = "6.0.0" @@ -3744,7 +3821,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3989,7 +4066,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4322,6 +4399,16 @@ dependencies = [ "libc", ] +[[package]] +name = "fslock" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04412b8935272e3a9bae6f48c7bfff74c2911f60525404edfdd28e49884c3bfb" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "futures" version = "0.3.31" @@ -4550,6 +4637,15 @@ dependencies = [ "regex-syntax 0.8.8", ] +[[package]] +name = "gzip-header" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95cc527b92e6029a62960ad99aa8a6660faa4555fe5f731aab13aa6a921795a2" +dependencies = [ + "crc32fast", +] + [[package]] name = "h2" version = "0.4.13" @@ -5007,6 +5103,28 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_calendar" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f0e52e009b6b16ba9c0693578796f2dd4aaa59a7f8f920423706714a89ac4e" +dependencies = [ + "calendrical_calculations", + "displaydoc", + "icu_calendar_data", + "icu_locale", + "icu_locale_core", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_calendar_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527f04223b17edfe0bd43baf14a0cb1b017830db65f3950dc00224860a9a446d" + [[package]] name = "icu_collections" version = "2.1.1" @@ -5393,7 +5511,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5441,6 +5559,12 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "ixdtf" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84de9d95a6d2547d9b77ee3f25fa0ee32e3c3a6484d47a55adebc0439c077992" + [[package]] name = "jiff" version = "0.2.18" @@ -6150,7 +6274,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6794,7 +6918,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.45.0", ] [[package]] @@ -7166,6 +7290,16 @@ dependencies = [ "yansi", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.114", +] + [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -7999,6 +8133,16 @@ dependencies = [ "webpki-roots 1.0.5", ] +[[package]] +name = "resb" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a067ab3b5ca3b4dc307d0de9cf75f9f5e6ca9717b192b2f28a36c83e5de9e76" +dependencies = [ + "potential_utf", + "serde_core", +] + [[package]] name = "resolv-conf" version = "0.7.6" @@ -8196,7 +8340,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8239,9 +8383,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ "aws-lc-rs", "ring", @@ -9379,6 +9523,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "strck" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42316e70da376f3d113a68d138a60d8a9883c604fe97942721ec2068dab13a9f" +dependencies = [ + "unicode-ident", +] + [[package]] name = "streaming-iterator" version = "0.1.9" @@ -9601,9 +9754,9 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" [[package]] name = "tar" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" dependencies = [ "filetime", "libc", @@ -9620,7 +9773,40 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.3", - "windows-sys 0.61.2", + "windows-sys 0.52.0", +] + +[[package]] +name = "temporal_capi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a151e402c2bdb6a3a2a2f3f225eddaead2e7ce7dd5d3fa2090deb11b17aa4ed8" +dependencies = [ + "diplomat", + "diplomat-runtime", + "icu_calendar", + "icu_locale", + "num-traits", + "temporal_rs", + "timezone_provider", + "writeable", + "zoneinfo64", +] + +[[package]] +name = "temporal_rs" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88afde3bd75d2fc68d77a914bece426aa08aa7649ffd0cdd4a11c3d4d33474d1" +dependencies = [ + "core_maths", + "icu_calendar", + "icu_locale", + "ixdtf", + "num-traits", + "timezone_provider", + "tinystr", + "writeable", ] [[package]] @@ -9830,6 +10016,18 @@ dependencies = [ "time-core", ] +[[package]] +name = "timezone_provider" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9ba0000e9e73862f3e7ca1ff159e2ddf915c9d8bb11e38a7874760f445d993" +dependencies = [ + "tinystr", + "zerotrie", + "zerovec", + "zoneinfo64", +] + [[package]] name = "tiny-keccak" version = "2.0.2" @@ -10629,6 +10827,23 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v8" +version = "146.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d97bcac5cdc5a195a4813f1855a6bc658f240452aac36caa12fd6c6f16026ab1" +dependencies = [ + "bindgen", + "bitflags 2.10.0", + "fslock", + "gzip-header", + "home", + "miniz_oxide", + "paste", + "temporal_capi", + "which 6.0.3", +] + [[package]] name = "valuable" version = "0.1.1" @@ -10928,6 +11143,18 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "which" +version = "6.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" +dependencies = [ + "either", + "home", + "rustix 0.38.44", + "winsafe", +] + [[package]] name = "which" version = "8.0.0" @@ -10992,7 +11219,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -11931,6 +12158,19 @@ version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" +[[package]] +name = "zoneinfo64" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb2e5597efbe7c421da8a7fd396b20b571704e787c21a272eecf35dfe9d386f0" +dependencies = [ + "calendrical_calculations", + "icu_locale_core", + "potential_utf", + "resb", + "serde", +] + [[package]] name = "zopfli" version = "0.8.3" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 331174a802..524b61e3b8 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -13,6 +13,7 @@ members = [ "feedback", "features", "codex-backend-openapi-models", + "code-mode", "cloud-requirements", "cloud-tasks", "cloud-tasks-client", @@ -45,6 +46,7 @@ members = [ "otel", "tui", "tui_app_server", + "v8-poc", "utils/absolute-path", "utils/cargo-bin", "utils/git", @@ -90,6 +92,7 @@ app_test_support = { path = "app-server/tests/common" } codex-ansi-escape = { path = "ansi-escape" } codex-api = { path = "codex-api" } codex-artifacts = { path = "artifacts" } +codex-code-mode = { path = "code-mode" } codex-package-manager = { path = "package-manager" } codex-app-server = { path = "app-server" } codex-app-server-client = { path = "app-server-client" } @@ -137,6 +140,7 @@ codex-test-macros = { path = "test-macros" } codex-terminal-detection = { path = "terminal-detection" } codex-tui = { path = "tui" } codex-tui-app-server = { path = "tui_app_server" } +codex-v8-poc = { path = "v8-poc" } codex-utils-absolute-path = { path = "utils/absolute-path" } codex-utils-approval-presets = { path = "utils/approval-presets" } codex-utils-cache = { path = "utils/cache" } @@ -245,6 +249,7 @@ regex-lite = "0.1.8" reqwest = "0.12" rmcp = { version = "0.15.0", default-features = false } runfiles = { git = "https://github.com/dzbarsky/rules_rust", rev = "b56cbaa8465e74127f1ea216f813cd377295ad81" } +v8 = "=146.4.0" rustls = { version = "0.23", default-features = false, features = [ "ring", "std", @@ -370,7 +375,8 @@ ignored = [ "icu_provider", "openssl-sys", "codex-utils-readiness", - "codex-secrets" + "codex-secrets", + "codex-v8-poc", ] [profile.release] diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 045301e090..7d192f0b01 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -83,6 +83,9 @@ ], "type": "object" }, + "AgentPath": { + "type": "string" + }, "AppBranding": { "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", "properties": { @@ -511,6 +514,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -1620,6 +1645,13 @@ ], "type": "object" }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", @@ -1999,6 +2031,17 @@ "null" ] }, + "agent_path": { + "anyOf": [ + { + "$ref": "#/definitions/AgentPath" + }, + { + "type": "null" + } + ], + "default": null + }, "agent_role": { "default": null, "type": [ @@ -3054,6 +3097,26 @@ ], "type": "object" }, + "ThreadRealtimeTranscriptUpdatedNotification": { + "description": "EXPERIMENTAL - flat transcript delta emitted whenever realtime transcript text changes.", + "properties": { + "role": { + "type": "string" + }, + "text": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "role", + "text", + "threadId" + ], + "type": "object" + }, "ThreadStartedNotification": { "properties": { "thread": { @@ -4551,6 +4614,26 @@ "title": "Thread/realtime/itemAddedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "thread/realtime/transcriptUpdated" + ], + "title": "Thread/realtime/transcriptUpdatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadRealtimeTranscriptUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/realtime/transcriptUpdatedNotification", + "type": "object" + }, { "properties": { "method": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 9f81dd07af..8a870fc553 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -4274,6 +4274,26 @@ "title": "Thread/realtime/itemAddedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "thread/realtime/transcriptUpdated" + ], + "title": "Thread/realtime/transcriptUpdatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadRealtimeTranscriptUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/realtime/transcriptUpdatedNotification", + "type": "object" + }, { "properties": { "method": { @@ -4899,6 +4919,9 @@ "title": "AgentMessageDeltaNotification", "type": "object" }, + "AgentPath": { + "type": "string" + }, "AnalyticsConfig": { "additionalProperties": true, "properties": { @@ -5635,6 +5658,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/v2/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -9092,6 +9137,13 @@ }, "type": "object" }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "OverriddenMetadata": { "properties": { "effectiveValue": true, @@ -11547,6 +11599,17 @@ "null" ] }, + "agent_path": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AgentPath" + }, + { + "type": "null" + } + ], + "default": null + }, "agent_role": { "default": null, "type": [ @@ -13060,6 +13123,28 @@ "title": "ThreadRealtimeStartedNotification", "type": "object" }, + "ThreadRealtimeTranscriptUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - flat transcript delta emitted whenever realtime transcript text changes.", + "properties": { + "role": { + "type": "string" + }, + "text": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "role", + "text", + "threadId" + ], + "title": "ThreadRealtimeTranscriptUpdatedNotification", + "type": "object" + }, "ThreadResumeParams": { "$schema": "http://json-schema.org/draft-07/schema#", "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nThe precedence is: history > path > thread_id. If using history or path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 91dfa16fc9..32738eb065 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -139,6 +139,9 @@ "title": "AgentMessageDeltaNotification", "type": "object" }, + "AgentPath": { + "type": "string" + }, "AnalyticsConfig": { "additionalProperties": true, "properties": { @@ -2248,6 +2251,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -5860,6 +5885,13 @@ }, "type": "object" }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "OverriddenMetadata": { "properties": { "effectiveValue": true, @@ -8738,6 +8770,26 @@ "title": "Thread/realtime/itemAddedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "thread/realtime/transcriptUpdated" + ], + "title": "Thread/realtime/transcriptUpdatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadRealtimeTranscriptUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/realtime/transcriptUpdatedNotification", + "type": "object" + }, { "properties": { "method": { @@ -9307,6 +9359,17 @@ "null" ] }, + "agent_path": { + "anyOf": [ + { + "$ref": "#/definitions/AgentPath" + }, + { + "type": "null" + } + ], + "default": null + }, "agent_role": { "default": null, "type": [ @@ -10820,6 +10883,28 @@ "title": "ThreadRealtimeStartedNotification", "type": "object" }, + "ThreadRealtimeTranscriptUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - flat transcript delta emitted whenever realtime transcript text changes.", + "properties": { + "role": { + "type": "string" + }, + "text": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "role", + "text", + "threadId" + ], + "title": "ThreadRealtimeTranscriptUpdatedNotification", + "type": "object" + }, "ThreadResumeParams": { "$schema": "http://json-schema.org/draft-07/schema#", "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nThe precedence is: history > path > thread_id. If using history or path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json index 264a901c0f..032743d55d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json @@ -112,9 +112,38 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "TurnError": { "properties": { "additionalDetails": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index 98b485b578..2e0c3605e7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -131,6 +131,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -494,6 +516,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 8aee99f90c..5ae0c5a122 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -5,6 +5,9 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "AgentPath": { + "type": "string" + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", "enum": [ @@ -193,6 +196,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -586,6 +611,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", @@ -900,6 +932,17 @@ "null" ] }, + "agent_path": { + "anyOf": [ + { + "$ref": "#/definitions/AgentPath" + }, + { + "type": "null" + } + ], + "default": null + }, "agent_role": { "default": null, "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index 05f3ae87c0..126d78603a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -1,6 +1,9 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AgentPath": { + "type": "string" + }, "ByteRange": { "properties": { "end": { @@ -131,6 +134,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -517,6 +542,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", @@ -658,6 +690,17 @@ "null" ] }, + "agent_path": { + "anyOf": [ + { + "$ref": "#/definitions/AgentPath" + }, + { + "type": "null" + } + ], + "default": null + }, "agent_role": { "default": null, "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 214c25f540..dfdab228df 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -1,6 +1,9 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AgentPath": { + "type": "string" + }, "ByteRange": { "properties": { "end": { @@ -131,6 +134,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -517,6 +542,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", @@ -658,6 +690,17 @@ "null" ] }, + "agent_path": { + "anyOf": [ + { + "$ref": "#/definitions/AgentPath" + }, + { + "type": "null" + } + ], + "default": null + }, "agent_role": { "default": null, "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 2a8fe06ece..8f48dee4b7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -1,6 +1,9 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AgentPath": { + "type": "string" + }, "ByteRange": { "properties": { "end": { @@ -131,6 +134,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -517,6 +542,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", @@ -658,6 +690,17 @@ "null" ] }, + "agent_path": { + "anyOf": [ + { + "$ref": "#/definitions/AgentPath" + }, + { + "type": "null" + } + ], + "default": null + }, "agent_role": { "default": null, "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRealtimeTranscriptUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRealtimeTranscriptUpdatedNotification.json new file mode 100644 index 0000000000..2c6860fa31 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRealtimeTranscriptUpdatedNotification.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - flat transcript delta emitted whenever realtime transcript text changes.", + "properties": { + "role": { + "type": "string" + }, + "text": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "role", + "text", + "threadId" + ], + "title": "ThreadRealtimeTranscriptUpdatedNotification", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 468325cef1..edccc337da 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -5,6 +5,9 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "AgentPath": { + "type": "string" + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", "enum": [ @@ -193,6 +196,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -586,6 +611,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", @@ -900,6 +932,17 @@ "null" ] }, + "agent_path": { + "anyOf": [ + { + "$ref": "#/definitions/AgentPath" + }, + { + "type": "null" + } + ], + "default": null + }, "agent_role": { "default": null, "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index def818dcfa..cc41aac27f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -1,6 +1,9 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AgentPath": { + "type": "string" + }, "ByteRange": { "properties": { "end": { @@ -131,6 +134,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -517,6 +542,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", @@ -658,6 +690,17 @@ "null" ] }, + "agent_path": { + "anyOf": [ + { + "$ref": "#/definitions/AgentPath" + }, + { + "type": "null" + } + ], + "default": null + }, "agent_role": { "default": null, "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index c225b1c0f2..c3b50fee3b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -5,6 +5,9 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "AgentPath": { + "type": "string" + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", "enum": [ @@ -193,6 +196,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -586,6 +611,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", @@ -900,6 +932,17 @@ "null" ] }, + "agent_path": { + "anyOf": [ + { + "$ref": "#/definitions/AgentPath" + }, + { + "type": "null" + } + ], + "default": null + }, "agent_role": { "default": null, "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index df7670cdb7..2240150394 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -1,6 +1,9 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AgentPath": { + "type": "string" + }, "ByteRange": { "properties": { "end": { @@ -131,6 +134,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -517,6 +542,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", @@ -658,6 +690,17 @@ "null" ] }, + "agent_path": { + "anyOf": [ + { + "$ref": "#/definitions/AgentPath" + }, + { + "type": "null" + } + ], + "default": null + }, "agent_role": { "default": null, "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index d95cd4dd89..41b0d2d409 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -1,6 +1,9 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AgentPath": { + "type": "string" + }, "ByteRange": { "properties": { "end": { @@ -131,6 +134,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -517,6 +542,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", @@ -658,6 +690,17 @@ "null" ] }, + "agent_path": { + "anyOf": [ + { + "$ref": "#/definitions/AgentPath" + }, + { + "type": "null" + } + ], + "default": null + }, "agent_role": { "default": null, "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index b0220247aa..770cc920cf 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -131,6 +131,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -494,6 +516,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index cd9f63bb6c..7f1c3e4948 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -131,6 +131,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -494,6 +516,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 3cc16db922..761ddc9a62 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -131,6 +131,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -494,6 +516,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", diff --git a/codex-rs/app-server-protocol/schema/typescript/AgentPath.ts b/codex-rs/app-server-protocol/schema/typescript/AgentPath.ts new file mode 100644 index 0000000000..6e55ce69e2 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/AgentPath.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AgentPath = string; diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts index f8796deb79..d9e2df7797 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts @@ -41,6 +41,7 @@ import type { ThreadRealtimeErrorNotification } from "./v2/ThreadRealtimeErrorNo import type { ThreadRealtimeItemAddedNotification } from "./v2/ThreadRealtimeItemAddedNotification"; import type { ThreadRealtimeOutputAudioDeltaNotification } from "./v2/ThreadRealtimeOutputAudioDeltaNotification"; import type { ThreadRealtimeStartedNotification } from "./v2/ThreadRealtimeStartedNotification"; +import type { ThreadRealtimeTranscriptUpdatedNotification } from "./v2/ThreadRealtimeTranscriptUpdatedNotification"; import type { ThreadStartedNotification } from "./v2/ThreadStartedNotification"; import type { ThreadStatusChangedNotification } from "./v2/ThreadStatusChangedNotification"; import type { ThreadTokenUsageUpdatedNotification } from "./v2/ThreadTokenUsageUpdatedNotification"; @@ -55,4 +56,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW /** * Notification sent from the server to the client. */ -export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; +export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcriptUpdated", "params": ThreadRealtimeTranscriptUpdatedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; diff --git a/codex-rs/app-server-protocol/schema/typescript/SubAgentSource.ts b/codex-rs/app-server-protocol/schema/typescript/SubAgentSource.ts index df261bf3ea..669e5802b1 100644 --- a/codex-rs/app-server-protocol/schema/typescript/SubAgentSource.ts +++ b/codex-rs/app-server-protocol/schema/typescript/SubAgentSource.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AgentPath } from "./AgentPath"; import type { ThreadId } from "./ThreadId"; -export type SubAgentSource = "review" | "compact" | { "thread_spawn": { parent_thread_id: ThreadId, depth: number, agent_nickname: string | null, agent_role: string | null, } } | "memory_consolidation" | { "other": string }; +export type SubAgentSource = "review" | "compact" | { "thread_spawn": { parent_thread_id: ThreadId, depth: number, agent_path: AgentPath | null, agent_nickname: string | null, agent_role: string | null, } } | "memory_consolidation" | { "other": string }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index abbc1480c6..ce2d74cb16 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! export type { AbsolutePathBuf } from "./AbsolutePathBuf"; +export type { AgentPath } from "./AgentPath"; export type { ApplyPatchApprovalParams } from "./ApplyPatchApprovalParams"; export type { ApplyPatchApprovalResponse } from "./ApplyPatchApprovalResponse"; export type { AuthMode } from "./AuthMode"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts index 1ff409a41a..20dc3c5191 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { NonSteerableTurnKind } from "./NonSteerableTurnKind"; /** * This translation layer make sure that we expose codex error code in camel case. @@ -8,4 +9,4 @@ * When an upstream HTTP status is available (for example, from the Responses API or a provider), * it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant. */ -export type CodexErrorInfo = "contextWindowExceeded" | "usageLimitExceeded" | "serverOverloaded" | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | "other"; +export type CodexErrorInfo = "contextWindowExceeded" | "usageLimitExceeded" | "serverOverloaded" | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | { "activeTurnNotSteerable": { turnKind: NonSteerableTurnKind, } } | "other"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/NonSteerableTurnKind.ts b/codex-rs/app-server-protocol/schema/typescript/v2/NonSteerableTurnKind.ts new file mode 100644 index 0000000000..2624df2ba0 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/NonSteerableTurnKind.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type NonSteerableTurnKind = "review" | "compact"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeTranscriptUpdatedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeTranscriptUpdatedNotification.ts new file mode 100644 index 0000000000..d2940029f2 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeTranscriptUpdatedNotification.ts @@ -0,0 +1,9 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * EXPERIMENTAL - flat transcript delta emitted whenever realtime + * transcript text changes. + */ +export type ThreadRealtimeTranscriptUpdatedNotification = { threadId: string, role: string, text: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index f98d7676ff..c649aec06a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -194,6 +194,7 @@ export type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol"; export type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; export type { NetworkPolicyRuleAction } from "./NetworkPolicyRuleAction"; export type { NetworkRequirements } from "./NetworkRequirements"; +export type { NonSteerableTurnKind } from "./NonSteerableTurnKind"; export type { OverriddenMetadata } from "./OverriddenMetadata"; export type { PatchApplyStatus } from "./PatchApplyStatus"; export type { PatchChangeKind } from "./PatchChangeKind"; @@ -281,6 +282,7 @@ export type { ThreadRealtimeErrorNotification } from "./ThreadRealtimeErrorNotif export type { ThreadRealtimeItemAddedNotification } from "./ThreadRealtimeItemAddedNotification"; export type { ThreadRealtimeOutputAudioDeltaNotification } from "./ThreadRealtimeOutputAudioDeltaNotification"; export type { ThreadRealtimeStartedNotification } from "./ThreadRealtimeStartedNotification"; +export type { ThreadRealtimeTranscriptUpdatedNotification } from "./ThreadRealtimeTranscriptUpdatedNotification"; export type { ThreadResumeParams } from "./ThreadResumeParams"; export type { ThreadResumeResponse } from "./ThreadResumeResponse"; export type { ThreadRollbackParams } from "./ThreadRollbackParams"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 7e1dc78f20..56897566d9 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -923,6 +923,8 @@ server_notification_definitions! { ThreadRealtimeStarted => "thread/realtime/started" (v2::ThreadRealtimeStartedNotification), #[experimental("thread/realtime/itemAdded")] ThreadRealtimeItemAdded => "thread/realtime/itemAdded" (v2::ThreadRealtimeItemAddedNotification), + #[experimental("thread/realtime/transcriptUpdated")] + ThreadRealtimeTranscriptUpdated => "thread/realtime/transcriptUpdated" (v2::ThreadRealtimeTranscriptUpdatedNotification), #[experimental("thread/realtime/outputAudio/delta")] ThreadRealtimeOutputAudioDelta => "thread/realtime/outputAudio/delta" (v2::ThreadRealtimeOutputAudioDeltaNotification), #[experimental("thread/realtime/error")] diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index d43581aaf7..a1d5e55621 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -66,6 +66,7 @@ use codex_protocol::protocol::HookRunSummary as CoreHookRunSummary; use codex_protocol::protocol::HookScope as CoreHookScope; use codex_protocol::protocol::ModelRerouteReason as CoreModelRerouteReason; use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; +use codex_protocol::protocol::NonSteerableTurnKind as CoreNonSteerableTurnKind; use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow; @@ -128,6 +129,14 @@ macro_rules! v2_enum_from_core { }; } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum NonSteerableTurnKind { + Review, + Compact, +} + /// This translation layer make sure that we expose codex error code in camel case. /// /// When an upstream HTTP status is available (for example, from the Responses API or a provider), @@ -167,6 +176,13 @@ pub enum CodexErrorInfo { #[ts(rename = "httpStatusCode")] http_status_code: Option, }, + /// Returned when `turn/start` or `turn/steer` is submitted while the current active turn + /// cannot accept same-turn steering, for example `/review` or manual `/compact`. + ActiveTurnNotSteerable { + #[serde(rename = "turnKind")] + #[ts(rename = "turnKind")] + turn_kind: NonSteerableTurnKind, + }, Other, } @@ -193,11 +209,25 @@ impl From for CodexErrorInfo { CoreCodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code } => { CodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code } } + CoreCodexErrorInfo::ActiveTurnNotSteerable { turn_kind } => { + CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + } + } CoreCodexErrorInfo::Other => CodexErrorInfo::Other, } } } +impl From for NonSteerableTurnKind { + fn from(value: CoreNonSteerableTurnKind) -> Self { + match value { + CoreNonSteerableTurnKind::Review => Self::Review, + CoreNonSteerableTurnKind::Compact => Self::Compact, + } + } +} + #[derive( Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS, ExperimentalApi, )] @@ -3787,6 +3817,17 @@ pub struct ThreadRealtimeItemAddedNotification { pub item: JsonValue, } +/// EXPERIMENTAL - flat transcript delta emitted whenever realtime +/// transcript text changes. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeTranscriptUpdatedNotification { + pub thread_id: String, + pub role: String, + pub text: String, +} + /// EXPERIMENTAL - streamed output audio emitted by thread realtime. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] @@ -7821,6 +7862,22 @@ mod tests { ); } + #[test] + fn codex_error_info_serializes_active_turn_not_steerable_turn_kind_in_camel_case() { + let value = CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }; + + assert_eq!( + serde_json::to_value(value).unwrap(), + json!({ + "activeTurnNotSteerable": { + "turnKind": "review" + } + }) + ); + } + #[test] fn dynamic_tool_response_serializes_content_items() { let value = serde_json::to_value(DynamicToolCallResponse { diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 5ada340492..4d139b7f7e 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -140,7 +140,7 @@ Example with notification opt-out: - `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted. - `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success. - `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". -- `turn/steer` — add user input to an already in-flight turn without starting a new turn; returns the active `turnId` that accepted the input. +- `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. Review and manual compaction turns reject `turn/steer`. - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. - `thread/realtime/start` — start a thread-scoped realtime session (experimental); returns `{}` and streams `thread/realtime/*` notifications. - `thread/realtime/appendAudio` — append an input audio chunk to the active realtime session (experimental); returns `{}`. @@ -575,8 +575,8 @@ Use `thread/backgroundTerminals/clean` to terminate all running background termi ### Example: Steer an active turn -Use `turn/steer` to append additional user input to the currently active turn. This does not emit -`turn/started` and does not accept turn context overrides. +Use `turn/steer` to append additional user input to the currently active regular turn. This does +not emit `turn/started` and does not accept turn context overrides. ```json { "method": "turn/steer", "id": 32, "params": { @@ -587,7 +587,9 @@ Use `turn/steer` to append additional user input to the currently active turn. T { "id": 32, "result": { "turnId": "turn_456" } } ``` -`expectedTurnId` is required. If there is no active turn (or `expectedTurnId` does not match the active turn), the request fails with an `invalid request` error. +`expectedTurnId` is required. If there is no active turn, `expectedTurnId` does not match the +active turn, or the active turn kind does not accept same-turn steering (for example review or +manual compaction), the request fails with an `invalid request` error. ### Example: Request a code review @@ -825,7 +827,8 @@ The fuzzy file search session API emits per-query notifications: The thread realtime API emits thread-scoped notifications for session lifecycle and streaming media: - `thread/realtime/started` — `{ threadId, sessionId }` once realtime starts for the thread (experimental). -- `thread/realtime/itemAdded` — `{ threadId, item }` for non-audio realtime items (experimental). `item` is forwarded as raw JSON while the upstream websocket item schema remains unstable. +- `thread/realtime/itemAdded` — `{ threadId, item }` for raw non-audio realtime items that do not have a dedicated typed app-server notification, including `handoff_request` (experimental). `item` is forwarded as raw JSON while the upstream websocket item schema remains unstable. +- `thread/realtime/transcriptUpdated` — `{ threadId, role, text }` whenever realtime transcript text changes (experimental). This forwards the live transcript delta from that realtime event, not the full accumulated transcript. - `thread/realtime/outputAudio/delta` — `{ threadId, audio }` for streamed output audio chunks (experimental). `audio` uses camelCase fields (`data`, `sampleRate`, `numChannels`, `samplesPerChannel`). - `thread/realtime/error` — `{ threadId, message }` when realtime encounters a transport or backend error (experimental). - `thread/realtime/closed` — `{ threadId, reason }` when the realtime transport closes (experimental). @@ -917,6 +920,8 @@ There are additional item-specific events: - `ResponseStreamConnectionFailed { httpStatusCode? }`: failure to connect to the response SSE stream - `ResponseStreamDisconnected { httpStatusCode? }`: disconnect of the response SSE stream in the middle of a turn before completion - `ResponseTooManyFailedAttempts { httpStatusCode? }` +- `ActiveTurnNotSteerable { turnKind }`: `turn/start` or `turn/steer` was submitted while the + current active turn was not steerable, for example `/review` or manual `/compact` - `BadRequest` - `Unauthorized` - `SandboxError` diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 6b69393474..34640a50cf 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -86,6 +86,7 @@ use codex_app_server_protocol::ThreadRealtimeErrorNotification; use codex_app_server_protocol::ThreadRealtimeItemAddedNotification; use codex_app_server_protocol::ThreadRealtimeOutputAudioDeltaNotification; use codex_app_server_protocol::ThreadRealtimeStartedNotification; +use codex_app_server_protocol::ThreadRealtimeTranscriptUpdatedNotification; use codex_app_server_protocol::ThreadRollbackResponse; use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; @@ -397,8 +398,30 @@ pub(crate) async fn apply_bespoke_event_handling( )) .await; } - RealtimeEvent::InputTranscriptDelta(_) => {} - RealtimeEvent::OutputTranscriptDelta(_) => {} + RealtimeEvent::InputTranscriptDelta(event) => { + let notification = ThreadRealtimeTranscriptUpdatedNotification { + thread_id: conversation_id.to_string(), + role: "user".to_string(), + text: event.delta, + }; + outgoing + .send_server_notification( + ServerNotification::ThreadRealtimeTranscriptUpdated(notification), + ) + .await; + } + RealtimeEvent::OutputTranscriptDelta(event) => { + let notification = ThreadRealtimeTranscriptUpdatedNotification { + thread_id: conversation_id.to_string(), + role: "assistant".to_string(), + text: event.delta, + }; + outgoing + .send_server_notification( + ServerNotification::ThreadRealtimeTranscriptUpdated(notification), + ) + .await; + } RealtimeEvent::AudioOut(audio) => { let notification = ThreadRealtimeOutputAudioDeltaNotification { thread_id: conversation_id.to_string(), diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 06e2cd3ec3..8917f392dd 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -32,6 +32,7 @@ use codex_app_server_protocol::CancelLoginAccountParams; use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; @@ -161,6 +162,7 @@ use codex_app_server_protocol::ThreadUnsubscribeParams; use codex_app_server_protocol::ThreadUnsubscribeResponse; use codex_app_server_protocol::ThreadUnsubscribeStatus; use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnError; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; @@ -6131,24 +6133,57 @@ impl CodexMessageProcessor { self.outgoing.send_response(request_id, response).await; } Err(err) => { - let (code, message) = match err { + let (code, message, data) = match err { SteerInputError::NoActiveTurn(_) => ( INVALID_REQUEST_ERROR_CODE, "no active turn to steer".to_string(), + None, ), SteerInputError::ExpectedTurnMismatch { expected, actual } => ( INVALID_REQUEST_ERROR_CODE, format!("expected active turn id `{expected}` but found `{actual}`"), + None, ), + SteerInputError::ActiveTurnNotSteerable { turn_kind } => { + let message = match turn_kind { + codex_protocol::protocol::NonSteerableTurnKind::Review => { + "cannot steer a review turn".to_string() + } + codex_protocol::protocol::NonSteerableTurnKind::Compact => { + "cannot steer a compact turn".to_string() + } + }; + let error = TurnError { + message: message.clone(), + codex_error_info: Some( + AppServerCodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }, + ), + additional_details: None, + }; + let data = match serde_json::to_value(error) { + Ok(data) => Some(data), + Err(error) => { + tracing::error!( + ?error, + "failed to serialize active-turn-not-steerable turn error" + ); + None + } + }; + (INVALID_REQUEST_ERROR_CODE, message, data) + } SteerInputError::EmptyInput => ( INVALID_REQUEST_ERROR_CODE, "input must not be empty".to_string(), + None, ), }; let error = JSONRPCErrorError { code, message, - data: None, + data, }; self.outgoing.send_error(request_id, error).await; } @@ -8240,6 +8275,7 @@ fn with_thread_spawn_agent_metadata( codex_protocol::protocol::SubAgentSource::ThreadSpawn { parent_thread_id, depth, + agent_path, agent_nickname: existing_agent_nickname, agent_role: existing_agent_role, }, @@ -8247,6 +8283,7 @@ fn with_thread_spawn_agent_metadata( codex_protocol::protocol::SubAgentSource::ThreadSpawn { parent_thread_id, depth, + agent_path, agent_nickname: agent_nickname.or(existing_agent_nickname), agent_role: agent_role.or(existing_agent_role), }, @@ -8793,6 +8830,7 @@ mod tests { source: SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth: 1, + agent_path: None, agent_nickname: None, agent_role: None, }), @@ -8885,6 +8923,7 @@ mod tests { serde_json::to_string(&SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?, depth: 1, + agent_path: None, agent_nickname: None, agent_role: None, }))?; diff --git a/codex-rs/app-server/src/filters.rs b/codex-rs/app-server/src/filters.rs index a597509612..6d2b90dbae 100644 --- a/codex-rs/app-server/src/filters.rs +++ b/codex-rs/app-server/src/filters.rs @@ -133,6 +133,7 @@ mod tests { let spawn = CoreSessionSource::SubAgent(CoreSubAgentSource::ThreadSpawn { parent_thread_id, depth: 1, + agent_path: None, agent_nickname: None, agent_role: None, }); diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 8b4afc23d0..a4994d34b8 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -479,7 +479,7 @@ pub async fn run_main_with_transport( range: None, }); } - if let Some(warning) = codex_core::config::missing_system_bwrap_warning() { + if let Some(warning) = codex_core::config::system_bwrap_warning() { config_warnings.push(ConfigWarningNotification { summary: warning, details: None, diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index d70e8f47a1..287fe79755 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -53,6 +53,7 @@ use codex_core::ThreadManager; use codex_core::config::Config; use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::LoaderOverrides; +use codex_core::default_client::DEFAULT_ORIGINATOR; use codex_core::default_client::SetOriginatorError; use codex_core::default_client::USER_AGENT_SUFFIX; use codex_core::default_client::get_codex_user_agent; @@ -78,6 +79,7 @@ use toml::Value as TomlValue; use tracing::Instrument; const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10); +const TUI_APP_SERVER_CLIENT_NAME: &str = "codex-tui"; #[derive(Clone)] struct ExternalAuthRefreshBridge { @@ -551,7 +553,14 @@ impl MessageProcessor { } = params.client_info; session.app_server_client_name = Some(name.clone()); session.client_version = Some(version.clone()); - if let Err(error) = set_default_originator(name.clone()) { + let originator = if name == TUI_APP_SERVER_CLIENT_NAME { + // TODO: Remove this temporary workaround once app-server clients no longer + // need to retain the legacy TUI `codex_cli_rs` originator behavior. + DEFAULT_ORIGINATOR.to_string() + } else { + name.clone() + }; + if let Err(error) = set_default_originator(originator) { match error { SetOriginatorError::InvalidHeaderValue => { let error = JSONRPCErrorError { diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs index 8146f7ae93..b67390154e 100644 --- a/codex-rs/app-server/tests/common/rollout.rs +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -79,6 +79,7 @@ pub fn create_fake_rollout_with_source( originator: "codex".to_string(), cli_version: "0.0.0".to_string(), source, + agent_path: None, agent_nickname: None, agent_role: None, model_provider: model_provider.map(str::to_string), @@ -161,6 +162,7 @@ pub fn create_fake_rollout_with_text_elements( originator: "codex".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::Cli, + agent_path: None, agent_nickname: None, agent_role: None, model_provider: model_provider.map(str::to_string), diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index a95871430a..89b1090b84 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -25,6 +25,7 @@ use wiremock::ResponseTemplate; use wiremock::matchers::header; use wiremock::matchers::method; use wiremock::matchers::path; +use wiremock::matchers::query_param; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567"; @@ -678,6 +679,7 @@ async fn plugin_list_force_remote_sync_reconciles_curated_plugin_state() -> Resu .await; Mock::given(method("GET")) .and(path("/backend-api/plugins/featured")) + .and(query_param("platform", "codex")) .and(header("authorization", "Bearer chatgpt-token")) .and(header("chatgpt-account-id", "account-123")) .respond_with( @@ -784,6 +786,7 @@ async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> { .await; Mock::given(method("GET")) .and(path("/backend-api/plugins/featured")) + .and(query_param("platform", "codex")) .and(header("authorization", "Bearer chatgpt-token")) .and(header("chatgpt-account-id", "account-123")) .respond_with(ResponseTemplate::new(200).set_body_string(r#"["linear@openai-curated"]"#)) @@ -850,6 +853,7 @@ async fn plugin_list_fetches_featured_plugin_ids_without_chatgpt_auth() -> Resul Mock::given(method("GET")) .and(path("/backend-api/plugins/featured")) + .and(query_param("platform", "codex")) .respond_with(ResponseTemplate::new(200).set_body_string(r#"["linear@openai-curated"]"#)) .mount(&server) .await; @@ -888,6 +892,7 @@ async fn plugin_list_uses_warmed_featured_plugin_ids_cache_on_first_request() -> Mock::given(method("GET")) .and(path("/backend-api/plugins/featured")) + .and(query_param("platform", "codex")) .respond_with(ResponseTemplate::new(200).set_body_string(r#"["linear@openai-curated"]"#)) .expect(1) .mount(&server) diff --git a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs index 1073c1b938..e8a39efa64 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -21,6 +21,7 @@ use codex_app_server_protocol::ThreadRealtimeStartResponse; use codex_app_server_protocol::ThreadRealtimeStartedNotification; use codex_app_server_protocol::ThreadRealtimeStopParams; use codex_app_server_protocol::ThreadRealtimeStopResponse; +use codex_app_server_protocol::ThreadRealtimeTranscriptUpdatedNotification; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_features::FEATURES; @@ -66,6 +67,24 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { "content": [{ "type": "text", "text": "hi" }] } }), + json!({ + "type": "conversation.item.input_audio_transcription.delta", + "delta": "delegate now" + }), + json!({ + "type": "response.output_text.delta", + "delta": "working" + }), + json!({ + "type": "conversation.item.done", + "item": { + "id": "item_2", + "type": "function_call", + "name": "codex", + "call_id": "handoff_1", + "arguments": "{\"input_transcript\":\"delegate now\"}" + } + }), json!({ "type": "error", "message": "upstream boom" @@ -180,6 +199,40 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { assert_eq!(item_added.thread_id, output_audio.thread_id); assert_eq!(item_added.item["type"], json!("message")); + let first_transcript_update = read_notification::( + &mut mcp, + "thread/realtime/transcriptUpdated", + ) + .await?; + assert_eq!(first_transcript_update.thread_id, output_audio.thread_id); + assert_eq!(first_transcript_update.role, "user"); + assert_eq!(first_transcript_update.text, "delegate now"); + + let second_transcript_update = + read_notification::( + &mut mcp, + "thread/realtime/transcriptUpdated", + ) + .await?; + assert_eq!(second_transcript_update.thread_id, output_audio.thread_id); + assert_eq!(second_transcript_update.role, "assistant"); + assert_eq!(second_transcript_update.text, "working"); + + let handoff_item_added = read_notification::( + &mut mcp, + "thread/realtime/itemAdded", + ) + .await?; + assert_eq!(handoff_item_added.thread_id, output_audio.thread_id); + assert_eq!(handoff_item_added.item["type"], json!("handoff_request")); + assert_eq!(handoff_item_added.item["handoff_id"], json!("handoff_1")); + assert_eq!(handoff_item_added.item["item_id"], json!("item_2")); + assert_eq!( + handoff_item_added.item["input_transcript"], + json!("delegate now") + ); + assert_eq!(handoff_item_added.item["active_transcript"], json!([])); + let realtime_error = read_notification::(&mut mcp, "thread/realtime/error") .await?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 79031ddd3c..75bffe622c 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -661,6 +661,7 @@ async fn thread_list_filters_by_source_kind_subagent_thread_spawn() -> Result<() CoreSessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth: 1, + agent_path: None, agent_nickname: None, agent_role: None, }), @@ -724,6 +725,7 @@ async fn thread_list_filters_by_subagent_variant() -> Result<()> { CoreSessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth: 1, + agent_path: None, agent_nickname: None, agent_role: None, }), diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index fbbb3ec8fd..c91ffa133b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -322,6 +322,7 @@ stream_max_retries = 0 originator: "codex".to_string(), cli_version: "0.0.0".to_string(), source: RolloutSessionSource::Cli, + agent_path: None, agent_nickname: None, agent_role: None, model_provider: Some("mock_provider".to_string()), diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index e37a85dc1d..fb0f62a342 100644 --- a/codex-rs/cloud-requirements/src/lib.rs +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -883,6 +883,24 @@ mod tests { account_id: Option<&str>, access_token: &str, refresh_token: &str, + ) -> serde_json::Value { + chatgpt_auth_json_with_last_refresh( + plan_type, + chatgpt_user_id, + account_id, + access_token, + refresh_token, + "2025-01-01T00:00:00Z", + ) + } + + fn chatgpt_auth_json_with_last_refresh( + plan_type: &str, + chatgpt_user_id: Option<&str>, + account_id: Option<&str>, + access_token: &str, + refresh_token: &str, + last_refresh: &str, ) -> serde_json::Value { chatgpt_auth_json_with_mode( plan_type, @@ -890,6 +908,7 @@ mod tests { account_id, access_token, refresh_token, + last_refresh, None, ) } @@ -900,6 +919,7 @@ mod tests { account_id: Option<&str>, access_token: &str, refresh_token: &str, + last_refresh: &str, auth_mode: Option<&str>, ) -> serde_json::Value { let header = json!({ "alg": "none", "typ": "JWT" }); @@ -925,7 +945,7 @@ mod tests { "refresh_token": refresh_token, "account_id": account_id, }, - "last_refresh": "2025-01-01T00:00:00Z", + "last_refresh": last_refresh, }); if let Some(auth_mode) = auth_mode { auth_json["auth_mode"] = serde_json::Value::String(auth_mode.to_string()); @@ -1262,24 +1282,43 @@ enabled = false #[tokio::test] async fn fetch_cloud_requirements_recovers_after_unauthorized_reload() { - let auth = managed_auth_context( - "business", - Some("user-12345"), - Some("account-12345"), - "stale-access-token", - "test-refresh-token", - ); + let auth_home = tempdir().expect("tempdir"); write_auth_json( - auth._home.path(), - chatgpt_auth_json( + auth_home.path(), + chatgpt_auth_json_with_last_refresh( + "business", + Some("user-12345"), + Some("account-12345"), + "stale-access-token", + "test-refresh-token", + // Keep auth "fresh" so the first request hits unauthorized recovery + // instead of AuthManager::auth() proactively reloading from disk. + "3025-01-01T00:00:00Z", + ), + ) + .expect("write initial auth"); + let auth_manager = Arc::new(AuthManager::new( + auth_home.path().to_path_buf(), + false, + AuthCredentialsStoreMode::File, + )); + + write_auth_json( + auth_home.path(), + chatgpt_auth_json_with_last_refresh( "business", Some("user-12345"), Some("account-12345"), "fresh-access-token", "test-refresh-token", + "3025-01-01T00:00:00Z", ), ) .expect("write refreshed auth"); + let auth = ManagedAuthContext { + _home: auth_home, + manager: auth_manager, + }; let fetcher = Arc::new(TokenFetcher { expected_token: "fresh-access-token".to_string(), @@ -1314,24 +1353,41 @@ enabled = false #[tokio::test] async fn fetch_cloud_requirements_recovers_after_unauthorized_reload_updates_cache_identity() { - let auth = managed_auth_context( - "business", - Some("user-12345"), - Some("account-12345"), - "stale-access-token", - "test-refresh-token", - ); + let auth_home = tempdir().expect("tempdir"); write_auth_json( - auth._home.path(), - chatgpt_auth_json( + auth_home.path(), + chatgpt_auth_json_with_last_refresh( + "business", + Some("user-12345"), + Some("account-12345"), + "stale-access-token", + "test-refresh-token", + "3025-01-01T00:00:00Z", + ), + ) + .expect("write initial auth"); + let auth_manager = Arc::new(AuthManager::new( + auth_home.path().to_path_buf(), + false, + AuthCredentialsStoreMode::File, + )); + + write_auth_json( + auth_home.path(), + chatgpt_auth_json_with_last_refresh( "business", Some("user-99999"), Some("account-12345"), "fresh-access-token", "test-refresh-token", + "3025-01-01T00:00:00Z", ), ) .expect("write refreshed auth"); + let auth = ManagedAuthContext { + _home: auth_home, + manager: auth_manager, + }; let fetcher = Arc::new(TokenFetcher { expected_token: "fresh-access-token".to_string(), @@ -1432,6 +1488,7 @@ enabled = false Some("account-12345"), "test-access-token", "test-refresh-token", + "2025-01-01T00:00:00Z", Some("chatgptAuthTokens"), ), ) diff --git a/codex-rs/code-mode/BUILD.bazel b/codex-rs/code-mode/BUILD.bazel new file mode 100644 index 0000000000..bf39d9d5a5 --- /dev/null +++ b/codex-rs/code-mode/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "code-mode", + crate_name = "codex_code_mode", +) diff --git a/codex-rs/code-mode/Cargo.toml b/codex-rs/code-mode/Cargo.toml new file mode 100644 index 0000000000..e821ca0e4d --- /dev/null +++ b/codex-rs/code-mode/Cargo.toml @@ -0,0 +1,25 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-code-mode" +version.workspace = true + +[lib] +doctest = false +name = "codex_code_mode" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +async-trait = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] } +tokio-util = { workspace = true, features = ["rt"] } +tracing = { workspace = true } +v8 = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/code-mode/src/description.rs b/codex-rs/code-mode/src/description.rs new file mode 100644 index 0000000000..c875e2a1b1 --- /dev/null +++ b/codex-rs/code-mode/src/description.rs @@ -0,0 +1,555 @@ +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; + +use crate::PUBLIC_TOOL_NAME; + +const MAX_JS_SAFE_INTEGER: u64 = (1_u64 << 53) - 1; +const CODE_MODE_ONLY_PREFACE: &str = + "Use `exec/wait` tool to run all other tools, do not attempt to use any other tools directly"; +const EXEC_DESCRIPTION_TEMPLATE: &str = r#"## exec +- Runs raw JavaScript in an isolated context (no Node, no file system, or network access, no console). +- Send raw JavaScript source text, not JSON, quoted strings, or markdown code fences. +- You may optionally start the tool input with a first-line pragma like `// @exec: {"yield_time_ms": 10000, "max_output_tokens": 1000}`. +- `yield_time_ms` asks `exec` to yield early after that many milliseconds if the script is still running. +- `max_output_tokens` sets the token budget for direct `exec` results. By default the result is truncated to 10000 tokens. +- All nested tools are available on the global `tools` object, for example `await tools.exec_command(...)`. Tool names are exposed as normalized JavaScript identifiers, for example `await tools.mcp__ologs__get_profile(...)`. +- Tool methods take either string or object as parameter. +- They return either a structured value or a string based on the description above. + +- Global helpers: +- `exit()`: Immediately ends the current script successfully (like an early return from the top level). +- `text(value: string | number | boolean | undefined | null)`: Appends a text item. Non-string values are stringified with `JSON.stringify(...)` when possible. +- `image(imageUrlOrItem: string | { image_url: string; detail?: "auto" | "low" | "high" | "original" | null })`: Appends an image item. `image_url` can be an HTTPS URL or a base64-encoded `data:` URL. +- `store(key: string, value: any)`: stores a serializable value under a string key for later `exec` calls in the same session. +- `load(key: string)`: returns the stored value for a string key, or `undefined` if it is missing. +- `notify(value: string | number | boolean | undefined | null)`: immediately injects an extra `custom_tool_call_output` for the current `exec` call. Values are stringified like `text(...)`. +- `ALL_TOOLS`: metadata for the enabled nested tools as `{ name, description }` entries. +- `yield_control()`: yields the accumulated output to the model immediately while the script keeps running."#; +const WAIT_DESCRIPTION_TEMPLATE: &str = r#"- Use `wait` only after `exec` returns `Script running with cell ID ...`. +- `cell_id` identifies the running `exec` cell to resume. +- `yield_time_ms` controls how long to wait for more output before yielding again. If omitted, `wait` uses its default wait timeout. +- `max_tokens` limits how much new output this wait call returns. +- `terminate: true` stops the running cell instead of waiting for more output. +- `wait` returns only the new output since the last yield, or the final completion or termination result for that cell. +- If the cell is still running, `wait` may yield again with the same `cell_id`. +- If the cell has already finished, `wait` returns the completed result and closes the cell."#; + +pub const CODE_MODE_PRAGMA_PREFIX: &str = "// @exec:"; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CodeModeToolKind { + Function, + Freeform, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ToolDefinition { + pub name: String, + pub description: String, + pub kind: CodeModeToolKind, + pub input_schema: Option, + pub output_schema: Option, +} + +#[derive(Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +struct CodeModeExecPragma { + #[serde(default)] + yield_time_ms: Option, + #[serde(default)] + max_output_tokens: Option, +} + +#[derive(Debug, PartialEq, Eq)] +pub struct ParsedExecSource { + pub code: String, + pub yield_time_ms: Option, + pub max_output_tokens: Option, +} + +pub fn parse_exec_source(input: &str) -> Result { + if input.trim().is_empty() { + return Err( + "exec expects raw JavaScript source text (non-empty). Provide JS only, optionally with first-line `// @exec: {\"yield_time_ms\": 10000, \"max_output_tokens\": 1000}`.".to_string(), + ); + } + + let mut args = ParsedExecSource { + code: input.to_string(), + yield_time_ms: None, + max_output_tokens: None, + }; + + let mut lines = input.splitn(2, '\n'); + let first_line = lines.next().unwrap_or_default(); + let rest = lines.next().unwrap_or_default(); + let trimmed = first_line.trim_start(); + let Some(pragma) = trimmed.strip_prefix(CODE_MODE_PRAGMA_PREFIX) else { + return Ok(args); + }; + + if rest.trim().is_empty() { + return Err( + "exec pragma must be followed by JavaScript source on subsequent lines".to_string(), + ); + } + + let directive = pragma.trim(); + if directive.is_empty() { + return Err( + "exec pragma must be a JSON object with supported fields `yield_time_ms` and `max_output_tokens`" + .to_string(), + ); + } + + let value: serde_json::Value = serde_json::from_str(directive).map_err(|err| { + format!( + "exec pragma must be valid JSON with supported fields `yield_time_ms` and `max_output_tokens`: {err}" + ) + })?; + let object = value.as_object().ok_or_else(|| { + "exec pragma must be a JSON object with supported fields `yield_time_ms` and `max_output_tokens`" + .to_string() + })?; + for key in object.keys() { + match key.as_str() { + "yield_time_ms" | "max_output_tokens" => {} + _ => { + return Err(format!( + "exec pragma only supports `yield_time_ms` and `max_output_tokens`; got `{key}`" + )); + } + } + } + + let pragma: CodeModeExecPragma = serde_json::from_value(value).map_err(|err| { + format!( + "exec pragma fields `yield_time_ms` and `max_output_tokens` must be non-negative safe integers: {err}" + ) + })?; + if pragma + .yield_time_ms + .is_some_and(|yield_time_ms| yield_time_ms > MAX_JS_SAFE_INTEGER) + { + return Err( + "exec pragma field `yield_time_ms` must be a non-negative safe integer".to_string(), + ); + } + if pragma.max_output_tokens.is_some_and(|max_output_tokens| { + u64::try_from(max_output_tokens) + .map(|max_output_tokens| max_output_tokens > MAX_JS_SAFE_INTEGER) + .unwrap_or(true) + }) { + return Err( + "exec pragma field `max_output_tokens` must be a non-negative safe integer".to_string(), + ); + } + + args.code = rest.to_string(); + args.yield_time_ms = pragma.yield_time_ms; + args.max_output_tokens = pragma.max_output_tokens; + Ok(args) +} + +pub fn is_code_mode_nested_tool(tool_name: &str) -> bool { + tool_name != crate::PUBLIC_TOOL_NAME && tool_name != crate::WAIT_TOOL_NAME +} + +pub fn build_exec_tool_description( + enabled_tools: &[(String, String)], + code_mode_only: bool, +) -> String { + if !code_mode_only { + return EXEC_DESCRIPTION_TEMPLATE.to_string(); + } + + let mut sections = vec![ + CODE_MODE_ONLY_PREFACE.to_string(), + EXEC_DESCRIPTION_TEMPLATE.to_string(), + ]; + + if !enabled_tools.is_empty() { + let nested_tool_reference = enabled_tools + .iter() + .map(|(name, nested_description)| { + let global_name = normalize_code_mode_identifier(name); + format!( + "### `{global_name}` (`{name}`)\n{}", + nested_description.trim() + ) + }) + .collect::>() + .join("\n\n"); + sections.push(nested_tool_reference); + } + + sections.join("\n\n") +} + +pub fn build_wait_tool_description() -> &'static str { + WAIT_DESCRIPTION_TEMPLATE +} + +pub fn normalize_code_mode_identifier(tool_key: &str) -> String { + let mut identifier = String::new(); + + for (index, ch) in tool_key.chars().enumerate() { + let is_valid = if index == 0 { + ch == '_' || ch == '$' || ch.is_ascii_alphabetic() + } else { + ch == '_' || ch == '$' || ch.is_ascii_alphanumeric() + }; + + if is_valid { + identifier.push(ch); + } else { + identifier.push('_'); + } + } + + if identifier.is_empty() { + "_".to_string() + } else { + identifier + } +} + +pub fn augment_tool_definition(mut definition: ToolDefinition) -> ToolDefinition { + if definition.name != PUBLIC_TOOL_NAME { + definition.description = append_code_mode_sample_for_definition(&definition); + } + definition +} + +pub fn enabled_tool_metadata(definition: &ToolDefinition) -> EnabledToolMetadata { + EnabledToolMetadata { + tool_name: definition.name.clone(), + global_name: normalize_code_mode_identifier(&definition.name), + description: definition.description.clone(), + kind: definition.kind, + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct EnabledToolMetadata { + pub tool_name: String, + pub global_name: String, + pub description: String, + pub kind: CodeModeToolKind, +} + +pub fn append_code_mode_sample( + description: &str, + tool_name: &str, + input_name: &str, + input_type: String, + output_type: String, +) -> String { + let declaration = format!( + "declare const tools: {{ {} }};", + render_code_mode_tool_declaration(tool_name, input_name, input_type, output_type) + ); + format!("{description}\n\nexec tool declaration:\n```ts\n{declaration}\n```") +} + +fn append_code_mode_sample_for_definition(definition: &ToolDefinition) -> String { + let input_name = match definition.kind { + CodeModeToolKind::Function => "args", + CodeModeToolKind::Freeform => "input", + }; + let input_type = match definition.kind { + CodeModeToolKind::Function => definition + .input_schema + .as_ref() + .map(render_json_schema_to_typescript) + .unwrap_or_else(|| "unknown".to_string()), + CodeModeToolKind::Freeform => "string".to_string(), + }; + let output_type = definition + .output_schema + .as_ref() + .map(render_json_schema_to_typescript) + .unwrap_or_else(|| "unknown".to_string()); + append_code_mode_sample( + &definition.description, + &definition.name, + input_name, + input_type, + output_type, + ) +} + +fn render_code_mode_tool_declaration( + tool_name: &str, + input_name: &str, + input_type: String, + output_type: String, +) -> String { + let tool_name = normalize_code_mode_identifier(tool_name); + format!("{tool_name}({input_name}: {input_type}): Promise<{output_type}>;") +} + +pub fn render_json_schema_to_typescript(schema: &JsonValue) -> String { + render_json_schema_to_typescript_inner(schema) +} + +fn render_json_schema_to_typescript_inner(schema: &JsonValue) -> String { + match schema { + JsonValue::Bool(true) => "unknown".to_string(), + JsonValue::Bool(false) => "never".to_string(), + JsonValue::Object(map) => { + if let Some(value) = map.get("const") { + return render_json_schema_literal(value); + } + + if let Some(values) = map.get("enum").and_then(JsonValue::as_array) { + let rendered = values + .iter() + .map(render_json_schema_literal) + .collect::>(); + if !rendered.is_empty() { + return rendered.join(" | "); + } + } + + for key in ["anyOf", "oneOf"] { + if let Some(variants) = map.get(key).and_then(JsonValue::as_array) { + let rendered = variants + .iter() + .map(render_json_schema_to_typescript_inner) + .collect::>(); + if !rendered.is_empty() { + return rendered.join(" | "); + } + } + } + + if let Some(variants) = map.get("allOf").and_then(JsonValue::as_array) { + let rendered = variants + .iter() + .map(render_json_schema_to_typescript_inner) + .collect::>(); + if !rendered.is_empty() { + return rendered.join(" & "); + } + } + + if let Some(schema_type) = map.get("type") { + if let Some(types) = schema_type.as_array() { + let rendered = types + .iter() + .filter_map(JsonValue::as_str) + .map(|schema_type| render_json_schema_type_keyword(map, schema_type)) + .collect::>(); + if !rendered.is_empty() { + return rendered.join(" | "); + } + } + + if let Some(schema_type) = schema_type.as_str() { + return render_json_schema_type_keyword(map, schema_type); + } + } + + if map.contains_key("properties") + || map.contains_key("additionalProperties") + || map.contains_key("required") + { + return render_json_schema_object(map); + } + + if map.contains_key("items") || map.contains_key("prefixItems") { + return render_json_schema_array(map); + } + + "unknown".to_string() + } + _ => "unknown".to_string(), + } +} + +fn render_json_schema_type_keyword( + map: &serde_json::Map, + schema_type: &str, +) -> String { + match schema_type { + "string" => "string".to_string(), + "number" | "integer" => "number".to_string(), + "boolean" => "boolean".to_string(), + "null" => "null".to_string(), + "array" => render_json_schema_array(map), + "object" => render_json_schema_object(map), + _ => "unknown".to_string(), + } +} + +fn render_json_schema_array(map: &serde_json::Map) -> String { + if let Some(items) = map.get("items") { + let item_type = render_json_schema_to_typescript_inner(items); + return format!("Array<{item_type}>"); + } + + if let Some(items) = map.get("prefixItems").and_then(JsonValue::as_array) { + let item_types = items + .iter() + .map(render_json_schema_to_typescript_inner) + .collect::>(); + if !item_types.is_empty() { + return format!("[{}]", item_types.join(", ")); + } + } + + "unknown[]".to_string() +} + +fn render_json_schema_object(map: &serde_json::Map) -> String { + let required = map + .get("required") + .and_then(JsonValue::as_array) + .map(|items| { + items + .iter() + .filter_map(JsonValue::as_str) + .collect::>() + }) + .unwrap_or_default(); + let properties = map + .get("properties") + .and_then(JsonValue::as_object) + .cloned() + .unwrap_or_default(); + + let mut sorted_properties = properties.iter().collect::>(); + sorted_properties.sort_unstable_by(|(name_a, _), (name_b, _)| name_a.cmp(name_b)); + let mut lines = sorted_properties + .into_iter() + .map(|(name, value)| { + let optional = if required.iter().any(|required_name| required_name == name) { + "" + } else { + "?" + }; + let property_name = render_json_schema_property_name(name); + let property_type = render_json_schema_to_typescript_inner(value); + format!("{property_name}{optional}: {property_type};") + }) + .collect::>(); + + if let Some(additional_properties) = map.get("additionalProperties") { + let property_type = match additional_properties { + JsonValue::Bool(true) => Some("unknown".to_string()), + JsonValue::Bool(false) => None, + value => Some(render_json_schema_to_typescript_inner(value)), + }; + + if let Some(property_type) = property_type { + lines.push(format!("[key: string]: {property_type};")); + } + } else if properties.is_empty() { + lines.push("[key: string]: unknown;".to_string()); + } + + if lines.is_empty() { + return "{}".to_string(); + } + + format!("{{ {} }}", lines.join(" ")) +} + +fn render_json_schema_property_name(name: &str) -> String { + if normalize_code_mode_identifier(name) == name { + name.to_string() + } else { + serde_json::to_string(name).unwrap_or_else(|_| format!("\"{}\"", name.replace('"', "\\\""))) + } +} + +fn render_json_schema_literal(value: &JsonValue) -> String { + serde_json::to_string(value).unwrap_or_else(|_| "unknown".to_string()) +} + +#[cfg(test)] +mod tests { + use super::CodeModeToolKind; + use super::ParsedExecSource; + use super::ToolDefinition; + use super::augment_tool_definition; + use super::build_exec_tool_description; + use super::normalize_code_mode_identifier; + use super::parse_exec_source; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[test] + fn parse_exec_source_without_pragma() { + assert_eq!( + parse_exec_source("text('hi')").unwrap(), + ParsedExecSource { + code: "text('hi')".to_string(), + yield_time_ms: None, + max_output_tokens: None, + } + ); + } + + #[test] + fn parse_exec_source_with_pragma() { + assert_eq!( + parse_exec_source("// @exec: {\"yield_time_ms\": 10}\ntext('hi')").unwrap(), + ParsedExecSource { + code: "text('hi')".to_string(), + yield_time_ms: Some(10), + max_output_tokens: None, + } + ); + } + + #[test] + fn normalize_identifier_rewrites_invalid_characters() { + assert_eq!( + "mcp__ologs__get_profile", + normalize_code_mode_identifier("mcp__ologs__get_profile") + ); + assert_eq!( + "hidden_dynamic_tool", + normalize_code_mode_identifier("hidden-dynamic-tool") + ); + } + + #[test] + fn augment_tool_definition_appends_typed_declaration() { + let definition = ToolDefinition { + name: "hidden_dynamic_tool".to_string(), + description: "Test tool".to_string(), + kind: CodeModeToolKind::Function, + input_schema: Some(json!({ + "type": "object", + "properties": { "city": { "type": "string" } }, + "required": ["city"], + "additionalProperties": false + })), + output_schema: Some(json!({ + "type": "object", + "properties": { "ok": { "type": "boolean" } }, + "required": ["ok"] + })), + }; + + let description = augment_tool_definition(definition).description; + assert!(description.contains("declare const tools")); + assert!( + description.contains( + "hidden_dynamic_tool(args: { city: string; }): Promise<{ ok: boolean; }>;" + ) + ); + } + + #[test] + fn code_mode_only_description_includes_nested_tools() { + let description = + build_exec_tool_description(&[("foo".to_string(), "bar".to_string())], true); + assert!(description.contains("### `foo` (`foo`)")); + } +} diff --git a/codex-rs/code-mode/src/lib.rs b/codex-rs/code-mode/src/lib.rs new file mode 100644 index 0000000000..841e568be3 --- /dev/null +++ b/codex-rs/code-mode/src/lib.rs @@ -0,0 +1,30 @@ +mod description; +mod response; +mod runtime; +mod service; + +pub use description::CODE_MODE_PRAGMA_PREFIX; +pub use description::CodeModeToolKind; +pub use description::ToolDefinition; +pub use description::append_code_mode_sample; +pub use description::augment_tool_definition; +pub use description::build_exec_tool_description; +pub use description::build_wait_tool_description; +pub use description::is_code_mode_nested_tool; +pub use description::normalize_code_mode_identifier; +pub use description::parse_exec_source; +pub use description::render_json_schema_to_typescript; +pub use response::FunctionCallOutputContentItem; +pub use response::ImageDetail; +pub use runtime::DEFAULT_EXEC_YIELD_TIME_MS; +pub use runtime::DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL; +pub use runtime::DEFAULT_WAIT_YIELD_TIME_MS; +pub use runtime::ExecuteRequest; +pub use runtime::RuntimeResponse; +pub use runtime::WaitRequest; +pub use service::CodeModeService; +pub use service::CodeModeTurnHost; +pub use service::CodeModeTurnWorker; + +pub const PUBLIC_TOOL_NAME: &str = "exec"; +pub const WAIT_TOOL_NAME: &str = "wait"; diff --git a/codex-rs/code-mode/src/response.rs b/codex-rs/code-mode/src/response.rs new file mode 100644 index 0000000000..43579fac85 --- /dev/null +++ b/codex-rs/code-mode/src/response.rs @@ -0,0 +1,24 @@ +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ImageDetail { + Auto, + Low, + High, + Original, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum FunctionCallOutputContentItem { + InputText { + text: String, + }, + InputImage { + image_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + detail: Option, + }, +} diff --git a/codex-rs/code-mode/src/runtime/callbacks.rs b/codex-rs/code-mode/src/runtime/callbacks.rs new file mode 100644 index 0000000000..b77ae82d68 --- /dev/null +++ b/codex-rs/code-mode/src/runtime/callbacks.rs @@ -0,0 +1,209 @@ +use crate::response::FunctionCallOutputContentItem; + +use super::EXIT_SENTINEL; +use super::RuntimeEvent; +use super::RuntimeState; +use super::value::json_to_v8; +use super::value::normalize_output_image; +use super::value::serialize_output_text; +use super::value::throw_type_error; +use super::value::v8_value_to_json; + +pub(super) fn tool_callback( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, + mut retval: v8::ReturnValue, +) { + let tool_name = args.data().to_rust_string_lossy(scope); + let input = if args.length() == 0 { + Ok(None) + } else { + v8_value_to_json(scope, args.get(0)) + }; + let input = match input { + Ok(input) => input, + Err(error_text) => { + throw_type_error(scope, &error_text); + return; + } + }; + + let Some(resolver) = v8::PromiseResolver::new(scope) else { + throw_type_error(scope, "failed to create tool promise"); + return; + }; + let promise = resolver.get_promise(scope); + + let resolver = v8::Global::new(scope, resolver); + let Some(state) = scope.get_slot_mut::() else { + throw_type_error(scope, "runtime state unavailable"); + return; + }; + let id = format!("tool-{}", state.next_tool_call_id); + state.next_tool_call_id = state.next_tool_call_id.saturating_add(1); + let event_tx = state.event_tx.clone(); + state.pending_tool_calls.insert(id.clone(), resolver); + let _ = event_tx.send(RuntimeEvent::ToolCall { + id, + name: tool_name, + input, + }); + retval.set(promise.into()); +} + +pub(super) fn text_callback( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, + mut retval: v8::ReturnValue, +) { + let value = if args.length() == 0 { + v8::undefined(scope).into() + } else { + args.get(0) + }; + let text = match serialize_output_text(scope, value) { + Ok(text) => text, + Err(error_text) => { + throw_type_error(scope, &error_text); + return; + } + }; + if let Some(state) = scope.get_slot::() { + let _ = state.event_tx.send(RuntimeEvent::ContentItem( + FunctionCallOutputContentItem::InputText { text }, + )); + } + retval.set(v8::undefined(scope).into()); +} + +pub(super) fn image_callback( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, + mut retval: v8::ReturnValue, +) { + let value = if args.length() == 0 { + v8::undefined(scope).into() + } else { + args.get(0) + }; + let image_item = match normalize_output_image(scope, value) { + Ok(image_item) => image_item, + Err(()) => return, + }; + if let Some(state) = scope.get_slot::() { + let _ = state.event_tx.send(RuntimeEvent::ContentItem(image_item)); + } + retval.set(v8::undefined(scope).into()); +} + +pub(super) fn store_callback( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, + _retval: v8::ReturnValue, +) { + let key = match args.get(0).to_string(scope) { + Some(key) => key.to_rust_string_lossy(scope), + None => { + throw_type_error(scope, "store key must be a string"); + return; + } + }; + let value = args.get(1); + let serialized = match v8_value_to_json(scope, value) { + Ok(Some(value)) => value, + Ok(None) => { + throw_type_error( + scope, + &format!("Unable to store {key:?}. Only plain serializable objects can be stored."), + ); + return; + } + Err(error_text) => { + throw_type_error(scope, &error_text); + return; + } + }; + if let Some(state) = scope.get_slot_mut::() { + state.stored_values.insert(key, serialized); + } +} + +pub(super) fn load_callback( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, + mut retval: v8::ReturnValue, +) { + let key = match args.get(0).to_string(scope) { + Some(key) => key.to_rust_string_lossy(scope), + None => { + throw_type_error(scope, "load key must be a string"); + return; + } + }; + let value = scope + .get_slot::() + .and_then(|state| state.stored_values.get(&key)) + .cloned(); + let Some(value) = value else { + retval.set(v8::undefined(scope).into()); + return; + }; + let Some(value) = json_to_v8(scope, &value) else { + throw_type_error(scope, "failed to load stored value"); + return; + }; + retval.set(value); +} + +pub(super) fn notify_callback( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, + mut retval: v8::ReturnValue, +) { + let value = if args.length() == 0 { + v8::undefined(scope).into() + } else { + args.get(0) + }; + let text = match serialize_output_text(scope, value) { + Ok(text) => text, + Err(error_text) => { + throw_type_error(scope, &error_text); + return; + } + }; + if text.trim().is_empty() { + throw_type_error(scope, "notify expects non-empty text"); + return; + } + if let Some(state) = scope.get_slot::() { + let _ = state.event_tx.send(RuntimeEvent::Notify { + call_id: state.tool_call_id.clone(), + text, + }); + } + retval.set(v8::undefined(scope).into()); +} + +pub(super) fn yield_control_callback( + scope: &mut v8::PinScope<'_, '_>, + _args: v8::FunctionCallbackArguments, + _retval: v8::ReturnValue, +) { + if let Some(state) = scope.get_slot::() { + let _ = state.event_tx.send(RuntimeEvent::YieldRequested); + } +} + +pub(super) fn exit_callback( + scope: &mut v8::PinScope<'_, '_>, + _args: v8::FunctionCallbackArguments, + _retval: v8::ReturnValue, +) { + if let Some(state) = scope.get_slot_mut::() { + state.exit_requested = true; + } + if let Some(error) = v8::String::new(scope, EXIT_SENTINEL) { + scope.throw_exception(error.into()); + } +} diff --git a/codex-rs/code-mode/src/runtime/globals.rs b/codex-rs/code-mode/src/runtime/globals.rs new file mode 100644 index 0000000000..371479497b --- /dev/null +++ b/codex-rs/code-mode/src/runtime/globals.rs @@ -0,0 +1,138 @@ +use super::RuntimeState; +use super::callbacks::exit_callback; +use super::callbacks::image_callback; +use super::callbacks::load_callback; +use super::callbacks::notify_callback; +use super::callbacks::store_callback; +use super::callbacks::text_callback; +use super::callbacks::tool_callback; +use super::callbacks::yield_control_callback; + +pub(super) fn install_globals(scope: &mut v8::PinScope<'_, '_>) -> Result<(), String> { + let global = scope.get_current_context().global(scope); + let console = v8::String::new(scope, "console") + .ok_or_else(|| "failed to allocate global `console`".to_string())?; + if global.delete(scope, console.into()) != Some(true) { + return Err("failed to remove global `console`".to_string()); + } + + let tools = build_tools_object(scope)?; + let all_tools = build_all_tools_value(scope)?; + let text = helper_function(scope, "text", text_callback)?; + let image = helper_function(scope, "image", image_callback)?; + let store = helper_function(scope, "store", store_callback)?; + let load = helper_function(scope, "load", load_callback)?; + let notify = helper_function(scope, "notify", notify_callback)?; + let yield_control = helper_function(scope, "yield_control", yield_control_callback)?; + let exit = helper_function(scope, "exit", exit_callback)?; + + set_global(scope, global, "tools", tools.into())?; + set_global(scope, global, "ALL_TOOLS", all_tools)?; + set_global(scope, global, "text", text.into())?; + set_global(scope, global, "image", image.into())?; + set_global(scope, global, "store", store.into())?; + set_global(scope, global, "load", load.into())?; + set_global(scope, global, "notify", notify.into())?; + set_global(scope, global, "yield_control", yield_control.into())?; + set_global(scope, global, "exit", exit.into())?; + Ok(()) +} + +fn build_tools_object<'s>( + scope: &mut v8::PinScope<'s, '_>, +) -> Result, String> { + let tools = v8::Object::new(scope); + let enabled_tools = scope + .get_slot::() + .map(|state| state.enabled_tools.clone()) + .unwrap_or_default(); + + for tool in enabled_tools { + let name = v8::String::new(scope, &tool.global_name) + .ok_or_else(|| "failed to allocate tool name".to_string())?; + let function = tool_function(scope, &tool.tool_name)?; + tools.set(scope, name.into(), function.into()); + } + Ok(tools) +} + +fn build_all_tools_value<'s>( + scope: &mut v8::PinScope<'s, '_>, +) -> Result, String> { + let enabled_tools = scope + .get_slot::() + .map(|state| state.enabled_tools.clone()) + .unwrap_or_default(); + let array = v8::Array::new(scope, enabled_tools.len() as i32); + let name_key = v8::String::new(scope, "name") + .ok_or_else(|| "failed to allocate ALL_TOOLS name key".to_string())?; + let description_key = v8::String::new(scope, "description") + .ok_or_else(|| "failed to allocate ALL_TOOLS description key".to_string())?; + + for (index, tool) in enabled_tools.iter().enumerate() { + let item = v8::Object::new(scope); + let name = v8::String::new(scope, &tool.global_name) + .ok_or_else(|| "failed to allocate ALL_TOOLS name".to_string())?; + let description = v8::String::new(scope, &tool.description) + .ok_or_else(|| "failed to allocate ALL_TOOLS description".to_string())?; + + if item.set(scope, name_key.into(), name.into()) != Some(true) { + return Err("failed to set ALL_TOOLS name".to_string()); + } + if item.set(scope, description_key.into(), description.into()) != Some(true) { + return Err("failed to set ALL_TOOLS description".to_string()); + } + if array.set_index(scope, index as u32, item.into()) != Some(true) { + return Err("failed to append ALL_TOOLS metadata".to_string()); + } + } + + Ok(array.into()) +} + +fn helper_function<'s, F>( + scope: &mut v8::PinScope<'s, '_>, + name: &str, + callback: F, +) -> Result, String> +where + F: v8::MapFnTo, +{ + let name = + v8::String::new(scope, name).ok_or_else(|| "failed to allocate helper name".to_string())?; + let template = v8::FunctionTemplate::builder(callback) + .data(name.into()) + .build(scope); + template + .get_function(scope) + .ok_or_else(|| "failed to create helper function".to_string()) +} + +fn tool_function<'s>( + scope: &mut v8::PinScope<'s, '_>, + tool_name: &str, +) -> Result, String> { + let data = v8::String::new(scope, tool_name) + .ok_or_else(|| "failed to allocate tool callback data".to_string())?; + let template = v8::FunctionTemplate::builder(tool_callback) + .data(data.into()) + .build(scope); + template + .get_function(scope) + .ok_or_else(|| "failed to create tool function".to_string()) +} + +fn set_global<'s>( + scope: &mut v8::PinScope<'s, '_>, + global: v8::Local<'s, v8::Object>, + name: &str, + value: v8::Local<'s, v8::Value>, +) -> Result<(), String> { + let key = v8::String::new(scope, name) + .ok_or_else(|| format!("failed to allocate global `{name}`"))?; + if global.set(scope, key.into(), value) == Some(true) { + Ok(()) + } else { + Err(format!("failed to set global `{name}`")) + } +} diff --git a/codex-rs/code-mode/src/runtime/mod.rs b/codex-rs/code-mode/src/runtime/mod.rs new file mode 100644 index 0000000000..df90eda673 --- /dev/null +++ b/codex-rs/code-mode/src/runtime/mod.rs @@ -0,0 +1,349 @@ +mod callbacks; +mod globals; +mod module_loader; +mod value; + +use std::collections::HashMap; +use std::sync::OnceLock; +use std::sync::mpsc as std_mpsc; +use std::thread; + +use serde_json::Value as JsonValue; +use tokio::sync::mpsc; + +use crate::description::EnabledToolMetadata; +use crate::description::ToolDefinition; +use crate::description::enabled_tool_metadata; +use crate::response::FunctionCallOutputContentItem; + +pub const DEFAULT_EXEC_YIELD_TIME_MS: u64 = 10_000; +pub const DEFAULT_WAIT_YIELD_TIME_MS: u64 = 10_000; +pub const DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL: usize = 10_000; +const EXIT_SENTINEL: &str = "__codex_code_mode_exit__"; + +#[derive(Clone, Debug)] +pub struct ExecuteRequest { + pub tool_call_id: String, + pub enabled_tools: Vec, + pub source: String, + pub stored_values: HashMap, + pub yield_time_ms: Option, + pub max_output_tokens: Option, +} + +#[derive(Clone, Debug)] +pub struct WaitRequest { + pub cell_id: String, + pub yield_time_ms: u64, + pub terminate: bool, +} + +#[derive(Debug, PartialEq)] +pub enum RuntimeResponse { + Yielded { + cell_id: String, + content_items: Vec, + }, + Terminated { + cell_id: String, + content_items: Vec, + }, + Result { + cell_id: String, + content_items: Vec, + stored_values: HashMap, + error_text: Option, + }, +} + +#[derive(Debug)] +pub(crate) enum TurnMessage { + ToolCall { + cell_id: String, + id: String, + name: String, + input: Option, + }, + Notify { + cell_id: String, + call_id: String, + text: String, + }, +} + +#[derive(Debug)] +pub(crate) enum RuntimeCommand { + ToolResponse { id: String, result: JsonValue }, + ToolError { id: String, error_text: String }, + Terminate, +} + +#[derive(Debug)] +pub(crate) enum RuntimeEvent { + Started, + ContentItem(FunctionCallOutputContentItem), + YieldRequested, + ToolCall { + id: String, + name: String, + input: Option, + }, + Notify { + call_id: String, + text: String, + }, + Result { + stored_values: HashMap, + error_text: Option, + }, +} + +pub(crate) fn spawn_runtime( + request: ExecuteRequest, + event_tx: mpsc::UnboundedSender, +) -> Result<(std_mpsc::Sender, v8::IsolateHandle), String> { + let (command_tx, command_rx) = std_mpsc::channel(); + let (isolate_handle_tx, isolate_handle_rx) = std_mpsc::sync_channel(1); + let enabled_tools = request + .enabled_tools + .iter() + .map(enabled_tool_metadata) + .collect::>(); + let config = RuntimeConfig { + tool_call_id: request.tool_call_id, + enabled_tools, + source: request.source, + stored_values: request.stored_values, + }; + + thread::spawn(move || { + run_runtime(config, event_tx, command_rx, isolate_handle_tx); + }); + + let isolate_handle = isolate_handle_rx + .recv() + .map_err(|_| "failed to initialize code mode runtime".to_string())?; + Ok((command_tx, isolate_handle)) +} + +#[derive(Clone)] +struct RuntimeConfig { + tool_call_id: String, + enabled_tools: Vec, + source: String, + stored_values: HashMap, +} + +pub(super) struct RuntimeState { + event_tx: mpsc::UnboundedSender, + pending_tool_calls: HashMap>, + stored_values: HashMap, + enabled_tools: Vec, + next_tool_call_id: u64, + tool_call_id: String, + exit_requested: bool, +} + +pub(super) enum CompletionState { + Pending, + Completed { + stored_values: HashMap, + error_text: Option, + }, +} + +fn initialize_v8() { + static PLATFORM: OnceLock> = OnceLock::new(); + + let _ = PLATFORM.get_or_init(|| { + let platform = v8::new_default_platform(0, false).make_shared(); + v8::V8::initialize_platform(platform.clone()); + v8::V8::initialize(); + platform + }); +} + +fn run_runtime( + config: RuntimeConfig, + event_tx: mpsc::UnboundedSender, + command_rx: std_mpsc::Receiver, + isolate_handle_tx: std_mpsc::SyncSender, +) { + initialize_v8(); + + let isolate = &mut v8::Isolate::new(v8::CreateParams::default()); + let isolate_handle = isolate.thread_safe_handle(); + if isolate_handle_tx.send(isolate_handle).is_err() { + return; + } + isolate.set_host_import_module_dynamically_callback(module_loader::dynamic_import_callback); + + v8::scope!(let scope, isolate); + let context = v8::Context::new(scope, Default::default()); + let scope = &mut v8::ContextScope::new(scope, context); + + scope.set_slot(RuntimeState { + event_tx: event_tx.clone(), + pending_tool_calls: HashMap::new(), + stored_values: config.stored_values, + enabled_tools: config.enabled_tools, + next_tool_call_id: 1, + tool_call_id: config.tool_call_id, + exit_requested: false, + }); + + if let Err(error_text) = globals::install_globals(scope) { + send_result(&event_tx, HashMap::new(), Some(error_text)); + return; + } + + let _ = event_tx.send(RuntimeEvent::Started); + + let pending_promise = match module_loader::evaluate_main_module(scope, &config.source) { + Ok(pending_promise) => pending_promise, + Err(error_text) => { + capture_scope_send_error(scope, &event_tx, Some(error_text)); + return; + } + }; + + match module_loader::completion_state(scope, pending_promise.as_ref()) { + CompletionState::Completed { + stored_values, + error_text, + } => { + send_result(&event_tx, stored_values, error_text); + return; + } + CompletionState::Pending => {} + } + + let mut pending_promise = pending_promise; + loop { + let Ok(command) = command_rx.recv() else { + break; + }; + match command { + RuntimeCommand::Terminate => break, + RuntimeCommand::ToolResponse { id, result } => { + if let Err(error_text) = + module_loader::resolve_tool_response(scope, &id, Ok(result)) + { + capture_scope_send_error(scope, &event_tx, Some(error_text)); + return; + } + } + RuntimeCommand::ToolError { id, error_text } => { + if let Err(runtime_error) = + module_loader::resolve_tool_response(scope, &id, Err(error_text)) + { + capture_scope_send_error(scope, &event_tx, Some(runtime_error)); + return; + } + } + } + + scope.perform_microtask_checkpoint(); + match module_loader::completion_state(scope, pending_promise.as_ref()) { + CompletionState::Completed { + stored_values, + error_text, + } => { + send_result(&event_tx, stored_values, error_text); + return; + } + CompletionState::Pending => {} + } + + if let Some(promise) = pending_promise.as_ref() { + let promise = v8::Local::new(scope, promise); + if promise.state() != v8::PromiseState::Pending { + pending_promise = None; + } + } + } +} + +fn capture_scope_send_error( + scope: &mut v8::PinScope<'_, '_>, + event_tx: &mpsc::UnboundedSender, + error_text: Option, +) { + let stored_values = scope + .get_slot::() + .map(|state| state.stored_values.clone()) + .unwrap_or_default(); + + send_result(event_tx, stored_values, error_text); +} + +fn send_result( + event_tx: &mpsc::UnboundedSender, + stored_values: HashMap, + error_text: Option, +) { + let _ = event_tx.send(RuntimeEvent::Result { + stored_values, + error_text, + }); +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::time::Duration; + + use pretty_assertions::assert_eq; + use tokio::sync::mpsc; + + use super::ExecuteRequest; + use super::RuntimeEvent; + use super::spawn_runtime; + + fn execute_request(source: &str) -> ExecuteRequest { + ExecuteRequest { + tool_call_id: "call_1".to_string(), + enabled_tools: Vec::new(), + source: source.to_string(), + stored_values: HashMap::new(), + yield_time_ms: Some(1), + max_output_tokens: None, + } + } + + #[tokio::test] + async fn terminate_execution_stops_cpu_bound_module() { + let (event_tx, mut event_rx) = mpsc::unbounded_channel(); + let (_runtime_tx, runtime_terminate_handle) = + spawn_runtime(execute_request("while (true) {}"), event_tx).unwrap(); + + let started_event = tokio::time::timeout(Duration::from_secs(1), event_rx.recv()) + .await + .unwrap() + .unwrap(); + assert!(matches!(started_event, RuntimeEvent::Started)); + + assert!(runtime_terminate_handle.terminate_execution()); + + let result_event = tokio::time::timeout(Duration::from_secs(1), event_rx.recv()) + .await + .unwrap() + .unwrap(); + let RuntimeEvent::Result { + stored_values, + error_text, + } = result_event + else { + panic!("expected runtime result after termination"); + }; + assert_eq!(stored_values, HashMap::new()); + assert!(error_text.is_some()); + + assert!( + tokio::time::timeout(Duration::from_secs(1), event_rx.recv()) + .await + .unwrap() + .is_none() + ); + } +} diff --git a/codex-rs/code-mode/src/runtime/module_loader.rs b/codex-rs/code-mode/src/runtime/module_loader.rs new file mode 100644 index 0000000000..83ce3d347a --- /dev/null +++ b/codex-rs/code-mode/src/runtime/module_loader.rs @@ -0,0 +1,235 @@ +use serde_json::Value as JsonValue; + +use super::CompletionState; +use super::EXIT_SENTINEL; +use super::RuntimeState; +use super::value::json_to_v8; +use super::value::value_to_error_text; + +pub(super) fn evaluate_main_module( + scope: &mut v8::PinScope<'_, '_>, + source_text: &str, +) -> Result>, String> { + let tc = std::pin::pin!(v8::TryCatch::new(scope)); + let mut tc = tc.init(); + let source = v8::String::new(&tc, source_text) + .ok_or_else(|| "failed to allocate exec source".to_string())?; + let origin = script_origin(&mut tc, "exec_main.mjs")?; + let mut source = v8::script_compiler::Source::new(source, Some(&origin)); + let module = v8::script_compiler::compile_module(&tc, &mut source).ok_or_else(|| { + tc.exception() + .map(|exception| value_to_error_text(&mut tc, exception)) + .unwrap_or_else(|| "unknown code mode exception".to_string()) + })?; + module + .instantiate_module(&tc, resolve_module_callback) + .ok_or_else(|| { + tc.exception() + .map(|exception| value_to_error_text(&mut tc, exception)) + .unwrap_or_else(|| "unknown code mode exception".to_string()) + })?; + let result = match module.evaluate(&tc) { + Some(result) => result, + None => { + if let Some(exception) = tc.exception() { + if is_exit_exception(&mut tc, exception) { + return Ok(None); + } + return Err(value_to_error_text(&mut tc, exception)); + } + return Err("unknown code mode exception".to_string()); + } + }; + tc.perform_microtask_checkpoint(); + + if result.is_promise() { + let promise = v8::Local::::try_from(result) + .map_err(|_| "failed to read exec promise".to_string())?; + return Ok(Some(v8::Global::new(&tc, promise))); + } + + Ok(None) +} + +fn is_exit_exception( + scope: &mut v8::PinScope<'_, '_>, + exception: v8::Local<'_, v8::Value>, +) -> bool { + scope + .get_slot::() + .map(|state| state.exit_requested) + .unwrap_or(false) + && exception.is_string() + && exception.to_rust_string_lossy(scope) == EXIT_SENTINEL +} + +pub(super) fn resolve_tool_response( + scope: &mut v8::PinScope<'_, '_>, + id: &str, + response: Result, +) -> Result<(), String> { + let resolver = { + let state = scope + .get_slot_mut::() + .ok_or_else(|| "runtime state unavailable".to_string())?; + state.pending_tool_calls.remove(id) + } + .ok_or_else(|| format!("unknown tool call `{id}`"))?; + + let tc = std::pin::pin!(v8::TryCatch::new(scope)); + let mut tc = tc.init(); + let resolver = v8::Local::new(&tc, &resolver); + match response { + Ok(result) => { + let value = json_to_v8(&mut tc, &result) + .ok_or_else(|| "failed to serialize tool response".to_string())?; + resolver.resolve(&tc, value); + } + Err(error_text) => { + let value = v8::String::new(&tc, &error_text) + .ok_or_else(|| "failed to allocate tool error".to_string())?; + resolver.reject(&tc, value.into()); + } + } + if tc.has_caught() { + return Err(tc + .exception() + .map(|exception| value_to_error_text(&mut tc, exception)) + .unwrap_or_else(|| "unknown code mode exception".to_string())); + } + Ok(()) +} + +pub(super) fn completion_state( + scope: &mut v8::PinScope<'_, '_>, + pending_promise: Option<&v8::Global>, +) -> CompletionState { + let stored_values = scope + .get_slot::() + .map(|state| state.stored_values.clone()) + .unwrap_or_default(); + + let Some(pending_promise) = pending_promise else { + return CompletionState::Completed { + stored_values, + error_text: None, + }; + }; + + let promise = v8::Local::new(scope, pending_promise); + match promise.state() { + v8::PromiseState::Pending => CompletionState::Pending, + v8::PromiseState::Fulfilled => CompletionState::Completed { + stored_values, + error_text: None, + }, + v8::PromiseState::Rejected => { + let result = promise.result(scope); + let error_text = if is_exit_exception(scope, result) { + None + } else { + Some(value_to_error_text(scope, result)) + }; + CompletionState::Completed { + stored_values, + error_text, + } + } + } +} + +fn script_origin<'s>( + scope: &mut v8::PinScope<'s, '_>, + resource_name_: &str, +) -> Result, String> { + let resource_name = v8::String::new(scope, resource_name_) + .ok_or_else(|| "failed to allocate script origin".to_string())?; + let source_map_url = v8::String::new(scope, resource_name_) + .ok_or_else(|| "failed to allocate source map url".to_string())?; + Ok(v8::ScriptOrigin::new( + scope, + resource_name.into(), + 0, + 0, + true, + 0, + Some(source_map_url.into()), + true, + false, + true, + None, + )) +} + +fn resolve_module_callback<'s>( + context: v8::Local<'s, v8::Context>, + specifier: v8::Local<'s, v8::String>, + _import_attributes: v8::Local<'s, v8::FixedArray>, + _referrer: v8::Local<'s, v8::Module>, +) -> Option> { + v8::callback_scope!(unsafe scope, context); + let specifier = specifier.to_rust_string_lossy(scope); + resolve_module(scope, &specifier) +} + +pub(super) fn dynamic_import_callback<'s>( + scope: &mut v8::PinScope<'s, '_>, + _host_defined_options: v8::Local<'s, v8::Data>, + _resource_name: v8::Local<'s, v8::Value>, + specifier: v8::Local<'s, v8::String>, + _import_attributes: v8::Local<'s, v8::FixedArray>, +) -> Option> { + let specifier = specifier.to_rust_string_lossy(scope); + let resolver = v8::PromiseResolver::new(scope)?; + + match resolve_module(scope, &specifier) { + Some(module) => { + if module.get_status() == v8::ModuleStatus::Uninstantiated + && module + .instantiate_module(scope, resolve_module_callback) + .is_none() + { + let error = v8::String::new(scope, "failed to instantiate module") + .map(Into::into) + .unwrap_or_else(|| v8::undefined(scope).into()); + resolver.reject(scope, error); + return Some(resolver.get_promise(scope)); + } + if matches!( + module.get_status(), + v8::ModuleStatus::Instantiated | v8::ModuleStatus::Evaluated + ) && module.evaluate(scope).is_none() + { + let error = v8::String::new(scope, "failed to evaluate module") + .map(Into::into) + .unwrap_or_else(|| v8::undefined(scope).into()); + resolver.reject(scope, error); + return Some(resolver.get_promise(scope)); + } + let namespace = module.get_module_namespace(); + resolver.resolve(scope, namespace); + Some(resolver.get_promise(scope)) + } + None => { + let error = v8::String::new(scope, "unsupported import in exec") + .map(Into::into) + .unwrap_or_else(|| v8::undefined(scope).into()); + resolver.reject(scope, error); + Some(resolver.get_promise(scope)) + } + } +} + +fn resolve_module<'s>( + scope: &mut v8::PinScope<'s, '_>, + specifier: &str, +) -> Option> { + if let Some(message) = + v8::String::new(scope, &format!("Unsupported import in exec: {specifier}")) + { + scope.throw_exception(message.into()); + } else { + scope.throw_exception(v8::undefined(scope).into()); + } + None +} diff --git a/codex-rs/code-mode/src/runtime/value.rs b/codex-rs/code-mode/src/runtime/value.rs new file mode 100644 index 0000000000..eb0280142c --- /dev/null +++ b/codex-rs/code-mode/src/runtime/value.rs @@ -0,0 +1,163 @@ +use serde_json::Value as JsonValue; + +use crate::response::FunctionCallOutputContentItem; +use crate::response::ImageDetail; + +pub(super) fn serialize_output_text( + scope: &mut v8::PinScope<'_, '_>, + value: v8::Local<'_, v8::Value>, +) -> Result { + if value.is_undefined() + || value.is_null() + || value.is_boolean() + || value.is_number() + || value.is_big_int() + || value.is_string() + { + return Ok(value.to_rust_string_lossy(scope)); + } + + let tc = std::pin::pin!(v8::TryCatch::new(scope)); + let mut tc = tc.init(); + if let Some(stringified) = v8::json::stringify(&tc, value) { + return Ok(stringified.to_rust_string_lossy(&tc)); + } + if tc.has_caught() { + return Err(tc + .exception() + .map(|exception| value_to_error_text(&mut tc, exception)) + .unwrap_or_else(|| "unknown code mode exception".to_string())); + } + Ok(value.to_rust_string_lossy(&tc)) +} + +pub(super) fn normalize_output_image( + scope: &mut v8::PinScope<'_, '_>, + value: v8::Local<'_, v8::Value>, +) -> Result { + let result = (|| -> Result { + let (image_url, detail) = if value.is_string() { + (value.to_rust_string_lossy(scope), None) + } else if value.is_object() && !value.is_array() { + let object = v8::Local::::try_from(value).map_err(|_| { + "image expects a non-empty image URL string or an object with image_url and optional detail".to_string() + })?; + let image_url_key = v8::String::new(scope, "image_url") + .ok_or_else(|| "failed to allocate image helper keys".to_string())?; + let detail_key = v8::String::new(scope, "detail") + .ok_or_else(|| "failed to allocate image helper keys".to_string())?; + let image_url = object + .get(scope, image_url_key.into()) + .filter(|value| value.is_string()) + .map(|value| value.to_rust_string_lossy(scope)) + .ok_or_else(|| { + "image expects a non-empty image URL string or an object with image_url and optional detail" + .to_string() + })?; + let detail = match object.get(scope, detail_key.into()) { + Some(value) if value.is_string() => Some(value.to_rust_string_lossy(scope)), + Some(value) if value.is_null() || value.is_undefined() => None, + Some(_) => return Err("image detail must be a string when provided".to_string()), + None => None, + }; + (image_url, detail) + } else { + return Err( + "image expects a non-empty image URL string or an object with image_url and optional detail" + .to_string(), + ); + }; + + if image_url.is_empty() { + return Err( + "image expects a non-empty image URL string or an object with image_url and optional detail" + .to_string(), + ); + } + let lower = image_url.to_ascii_lowercase(); + if !(lower.starts_with("http://") + || lower.starts_with("https://") + || lower.starts_with("data:")) + { + return Err("image expects an http(s) or data URL".to_string()); + } + + let detail = match detail { + Some(detail) => { + let normalized = detail.to_ascii_lowercase(); + Some(match normalized.as_str() { + "auto" => ImageDetail::Auto, + "low" => ImageDetail::Low, + "high" => ImageDetail::High, + "original" => ImageDetail::Original, + _ => { + return Err( + "image detail must be one of: auto, low, high, original".to_string() + ); + } + }) + } + None => None, + }; + + Ok(FunctionCallOutputContentItem::InputImage { image_url, detail }) + })(); + + match result { + Ok(item) => Ok(item), + Err(error_text) => { + throw_type_error(scope, &error_text); + Err(()) + } + } +} + +pub(super) fn v8_value_to_json( + scope: &mut v8::PinScope<'_, '_>, + value: v8::Local<'_, v8::Value>, +) -> Result, String> { + let tc = std::pin::pin!(v8::TryCatch::new(scope)); + let mut tc = tc.init(); + let Some(stringified) = v8::json::stringify(&tc, value) else { + if tc.has_caught() { + return Err(tc + .exception() + .map(|exception| value_to_error_text(&mut tc, exception)) + .unwrap_or_else(|| "unknown code mode exception".to_string())); + } + return Ok(None); + }; + serde_json::from_str(&stringified.to_rust_string_lossy(&tc)) + .map(Some) + .map_err(|err| format!("failed to serialize JavaScript value: {err}")) +} + +pub(super) fn json_to_v8<'s>( + scope: &mut v8::PinScope<'s, '_>, + value: &JsonValue, +) -> Option> { + let json = serde_json::to_string(value).ok()?; + let json = v8::String::new(scope, &json)?; + v8::json::parse(scope, json) +} + +pub(super) fn value_to_error_text( + scope: &mut v8::PinScope<'_, '_>, + value: v8::Local<'_, v8::Value>, +) -> String { + if value.is_object() + && let Ok(object) = v8::Local::::try_from(value) + && let Some(key) = v8::String::new(scope, "stack") + && let Some(stack) = object.get(scope, key.into()) + && stack.is_string() + { + return stack.to_rust_string_lossy(scope); + } + value.to_rust_string_lossy(scope) +} + +pub(super) fn throw_type_error(scope: &mut v8::PinScope<'_, '_>, message: &str) { + if let Some(message) = v8::String::new(scope, message) { + scope.throw_exception(message.into()); + } +} diff --git a/codex-rs/code-mode/src/service.rs b/codex-rs/code-mode/src/service.rs new file mode 100644 index 0000000000..260b891d36 --- /dev/null +++ b/codex-rs/code-mode/src/service.rs @@ -0,0 +1,673 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use async_trait::async_trait; +use serde_json::Value as JsonValue; +use tokio::sync::Mutex; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio_util::sync::CancellationToken; +use tracing::warn; + +use crate::FunctionCallOutputContentItem; +use crate::runtime::DEFAULT_EXEC_YIELD_TIME_MS; +use crate::runtime::ExecuteRequest; +use crate::runtime::RuntimeCommand; +use crate::runtime::RuntimeEvent; +use crate::runtime::RuntimeResponse; +use crate::runtime::TurnMessage; +use crate::runtime::WaitRequest; +use crate::runtime::spawn_runtime; + +#[async_trait] +pub trait CodeModeTurnHost: Send + Sync { + async fn invoke_tool( + &self, + tool_name: String, + input: Option, + cancellation_token: CancellationToken, + ) -> Result; + + async fn notify(&self, call_id: String, cell_id: String, text: String) -> Result<(), String>; +} + +#[derive(Clone)] +struct SessionHandle { + control_tx: mpsc::UnboundedSender, + runtime_tx: std::sync::mpsc::Sender, +} + +struct Inner { + stored_values: Mutex>, + sessions: Mutex>, + turn_message_tx: mpsc::UnboundedSender, + turn_message_rx: Arc>>, + next_cell_id: AtomicU64, +} + +pub struct CodeModeService { + inner: Arc, +} + +impl CodeModeService { + pub fn new() -> Self { + let (turn_message_tx, turn_message_rx) = mpsc::unbounded_channel(); + + Self { + inner: Arc::new(Inner { + stored_values: Mutex::new(HashMap::new()), + sessions: Mutex::new(HashMap::new()), + turn_message_tx, + turn_message_rx: Arc::new(Mutex::new(turn_message_rx)), + next_cell_id: AtomicU64::new(1), + }), + } + } + + pub async fn stored_values(&self) -> HashMap { + self.inner.stored_values.lock().await.clone() + } + + pub async fn replace_stored_values(&self, values: HashMap) { + *self.inner.stored_values.lock().await = values; + } + + pub async fn execute(&self, request: ExecuteRequest) -> Result { + let cell_id = self + .inner + .next_cell_id + .fetch_add(1, Ordering::Relaxed) + .to_string(); + let (event_tx, event_rx) = mpsc::unbounded_channel(); + let (runtime_tx, runtime_terminate_handle) = spawn_runtime(request.clone(), event_tx)?; + let (control_tx, control_rx) = mpsc::unbounded_channel(); + let (response_tx, response_rx) = oneshot::channel(); + + self.inner.sessions.lock().await.insert( + cell_id.clone(), + SessionHandle { + control_tx: control_tx.clone(), + runtime_tx: runtime_tx.clone(), + }, + ); + + tokio::spawn(run_session_control( + Arc::clone(&self.inner), + SessionControlContext { + cell_id: cell_id.clone(), + runtime_tx, + runtime_terminate_handle, + }, + event_rx, + control_rx, + response_tx, + request.yield_time_ms.unwrap_or(DEFAULT_EXEC_YIELD_TIME_MS), + )); + + response_rx + .await + .map_err(|_| "exec runtime ended unexpectedly".to_string()) + } + + pub async fn wait(&self, request: WaitRequest) -> Result { + let cell_id = request.cell_id.clone(); + let handle = self + .inner + .sessions + .lock() + .await + .get(&request.cell_id) + .cloned(); + let Some(handle) = handle else { + return Ok(missing_cell_response(cell_id)); + }; + let (response_tx, response_rx) = oneshot::channel(); + let control_message = if request.terminate { + SessionControlCommand::Terminate { response_tx } + } else { + SessionControlCommand::Poll { + yield_time_ms: request.yield_time_ms, + response_tx, + } + }; + if handle.control_tx.send(control_message).is_err() { + return Ok(missing_cell_response(cell_id)); + } + match response_rx.await { + Ok(response) => Ok(response), + Err(_) => Ok(missing_cell_response(request.cell_id)), + } + } + + pub fn start_turn_worker(&self, host: Arc) -> CodeModeTurnWorker { + let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); + let inner = Arc::clone(&self.inner); + let turn_message_rx = Arc::clone(&self.inner.turn_message_rx); + + tokio::spawn(async move { + loop { + let next_message = tokio::select! { + _ = &mut shutdown_rx => break, + message = async { + let mut turn_message_rx = turn_message_rx.lock().await; + turn_message_rx.recv().await + } => message, + }; + let Some(next_message) = next_message else { + break; + }; + match next_message { + TurnMessage::Notify { + cell_id, + call_id, + text, + } => { + if let Err(err) = host.notify(call_id, cell_id.clone(), text).await { + warn!( + "failed to deliver code mode notification for cell {cell_id}: {err}" + ); + } + } + TurnMessage::ToolCall { + cell_id, + id, + name, + input, + } => { + let host = Arc::clone(&host); + let inner = Arc::clone(&inner); + tokio::spawn(async move { + let response = host + .invoke_tool(name, input, CancellationToken::new()) + .await; + let runtime_tx = inner + .sessions + .lock() + .await + .get(&cell_id) + .map(|handle| handle.runtime_tx.clone()); + let Some(runtime_tx) = runtime_tx else { + return; + }; + let command = match response { + Ok(result) => RuntimeCommand::ToolResponse { id, result }, + Err(error_text) => RuntimeCommand::ToolError { id, error_text }, + }; + let _ = runtime_tx.send(command); + }); + } + } + } + }); + + CodeModeTurnWorker { + shutdown_tx: Some(shutdown_tx), + } + } +} + +impl Default for CodeModeService { + fn default() -> Self { + Self::new() + } +} + +pub struct CodeModeTurnWorker { + shutdown_tx: Option>, +} + +impl Drop for CodeModeTurnWorker { + fn drop(&mut self) { + if let Some(shutdown_tx) = self.shutdown_tx.take() { + let _ = shutdown_tx.send(()); + } + } +} + +enum SessionControlCommand { + Poll { + yield_time_ms: u64, + response_tx: oneshot::Sender, + }, + Terminate { + response_tx: oneshot::Sender, + }, +} + +struct PendingResult { + content_items: Vec, + stored_values: HashMap, + error_text: Option, +} + +struct SessionControlContext { + cell_id: String, + runtime_tx: std::sync::mpsc::Sender, + runtime_terminate_handle: v8::IsolateHandle, +} + +fn missing_cell_response(cell_id: String) -> RuntimeResponse { + RuntimeResponse::Result { + error_text: Some(format!("exec cell {cell_id} not found")), + cell_id, + content_items: Vec::new(), + stored_values: HashMap::new(), + } +} + +fn pending_result_response(cell_id: &str, result: PendingResult) -> RuntimeResponse { + RuntimeResponse::Result { + cell_id: cell_id.to_string(), + content_items: result.content_items, + stored_values: result.stored_values, + error_text: result.error_text, + } +} + +fn send_or_buffer_result( + cell_id: &str, + result: PendingResult, + response_tx: &mut Option>, + pending_result: &mut Option, +) -> bool { + if let Some(response_tx) = response_tx.take() { + let _ = response_tx.send(pending_result_response(cell_id, result)); + return true; + } + + *pending_result = Some(result); + false +} + +async fn run_session_control( + inner: Arc, + context: SessionControlContext, + mut event_rx: mpsc::UnboundedReceiver, + mut control_rx: mpsc::UnboundedReceiver, + initial_response_tx: oneshot::Sender, + initial_yield_time_ms: u64, +) { + let SessionControlContext { + cell_id, + runtime_tx, + runtime_terminate_handle, + } = context; + let mut content_items = Vec::new(); + let mut pending_result: Option = None; + let mut response_tx = Some(initial_response_tx); + let mut termination_requested = false; + let mut runtime_closed = false; + let mut yield_timer: Option>> = None; + + loop { + tokio::select! { + maybe_event = async { + if runtime_closed { + std::future::pending::>().await + } else { + event_rx.recv().await + } + } => { + let Some(event) = maybe_event else { + runtime_closed = true; + if termination_requested { + if let Some(response_tx) = response_tx.take() { + let _ = response_tx.send(RuntimeResponse::Terminated { + cell_id: cell_id.clone(), + content_items: std::mem::take(&mut content_items), + }); + } + break; + } + if pending_result.is_none() { + let result = PendingResult { + content_items: std::mem::take(&mut content_items), + stored_values: HashMap::new(), + error_text: Some("exec runtime ended unexpectedly".to_string()), + }; + if send_or_buffer_result( + &cell_id, + result, + &mut response_tx, + &mut pending_result, + ) { + break; + } + } + continue; + }; + match event { + RuntimeEvent::Started => { + yield_timer = Some(Box::pin(tokio::time::sleep(Duration::from_millis(initial_yield_time_ms)))); + } + RuntimeEvent::ContentItem(item) => { + content_items.push(item); + } + RuntimeEvent::YieldRequested => { + yield_timer = None; + if let Some(response_tx) = response_tx.take() { + let _ = response_tx.send(RuntimeResponse::Yielded { + cell_id: cell_id.clone(), + content_items: std::mem::take(&mut content_items), + }); + } + } + RuntimeEvent::Notify { call_id, text } => { + let _ = inner.turn_message_tx.send(TurnMessage::Notify { + cell_id: cell_id.clone(), + call_id, + text, + }); + } + RuntimeEvent::ToolCall { id, name, input } => { + let _ = inner.turn_message_tx.send(TurnMessage::ToolCall { + cell_id: cell_id.clone(), + id, + name, + input, + }); + } + RuntimeEvent::Result { + stored_values, + error_text, + } => { + yield_timer = None; + if termination_requested { + if let Some(response_tx) = response_tx.take() { + let _ = response_tx.send(RuntimeResponse::Terminated { + cell_id: cell_id.clone(), + content_items: std::mem::take(&mut content_items), + }); + } + break; + } + let result = PendingResult { + content_items: std::mem::take(&mut content_items), + stored_values, + error_text, + }; + if send_or_buffer_result( + &cell_id, + result, + &mut response_tx, + &mut pending_result, + ) { + break; + } + } + } + } + maybe_command = control_rx.recv() => { + let Some(command) = maybe_command else { + break; + }; + match command { + SessionControlCommand::Poll { + yield_time_ms, + response_tx: next_response_tx, + } => { + if let Some(result) = pending_result.take() { + let _ = next_response_tx.send(pending_result_response(&cell_id, result)); + break; + } + response_tx = Some(next_response_tx); + yield_timer = Some(Box::pin(tokio::time::sleep(Duration::from_millis(yield_time_ms)))); + } + SessionControlCommand::Terminate { response_tx: next_response_tx } => { + if let Some(result) = pending_result.take() { + let _ = next_response_tx.send(pending_result_response(&cell_id, result)); + break; + } + + response_tx = Some(next_response_tx); + termination_requested = true; + yield_timer = None; + let _ = runtime_tx.send(RuntimeCommand::Terminate); + let _ = runtime_terminate_handle.terminate_execution(); + if runtime_closed { + if let Some(response_tx) = response_tx.take() { + let _ = response_tx.send(RuntimeResponse::Terminated { + cell_id: cell_id.clone(), + content_items: std::mem::take(&mut content_items), + }); + } + break; + } else { + continue; + } + } + } + } + _ = async { + if let Some(yield_timer) = yield_timer.as_mut() { + yield_timer.await; + } else { + std::future::pending::<()>().await; + } + } => { + yield_timer = None; + if let Some(response_tx) = response_tx.take() { + let _ = response_tx.send(RuntimeResponse::Yielded { + cell_id: cell_id.clone(), + content_items: std::mem::take(&mut content_items), + }); + } + } + } + } + + let _ = runtime_tx.send(RuntimeCommand::Terminate); + inner.sessions.lock().await.remove(&cell_id); +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::Arc; + use std::sync::atomic::AtomicU64; + use std::time::Duration; + + use pretty_assertions::assert_eq; + use tokio::sync::Mutex; + use tokio::sync::mpsc; + use tokio::sync::oneshot; + + use super::CodeModeService; + use super::Inner; + use super::RuntimeCommand; + use super::RuntimeResponse; + use super::SessionControlCommand; + use super::SessionControlContext; + use super::run_session_control; + use crate::FunctionCallOutputContentItem; + use crate::runtime::ExecuteRequest; + use crate::runtime::RuntimeEvent; + use crate::runtime::spawn_runtime; + + fn execute_request(source: &str) -> ExecuteRequest { + ExecuteRequest { + tool_call_id: "call_1".to_string(), + enabled_tools: Vec::new(), + source: source.to_string(), + stored_values: HashMap::new(), + yield_time_ms: Some(1), + max_output_tokens: None, + } + } + + fn test_inner() -> Arc { + let (turn_message_tx, turn_message_rx) = mpsc::unbounded_channel(); + Arc::new(Inner { + stored_values: Mutex::new(HashMap::new()), + sessions: Mutex::new(HashMap::new()), + turn_message_tx, + turn_message_rx: Arc::new(Mutex::new(turn_message_rx)), + next_cell_id: AtomicU64::new(1), + }) + } + + #[tokio::test] + async fn synchronous_exit_returns_successfully() { + let service = CodeModeService::new(); + + let response = service + .execute(ExecuteRequest { + source: r#"text("before"); exit(); text("after");"#.to_string(), + yield_time_ms: None, + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: "1".to_string(), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "before".to_string(), + }], + stored_values: HashMap::new(), + error_text: None, + } + ); + } + + #[tokio::test] + async fn v8_console_is_not_exposed_on_global_this() { + let service = CodeModeService::new(); + + let response = service + .execute(ExecuteRequest { + source: r#"text(String(Object.hasOwn(globalThis, "console")));"#.to_string(), + yield_time_ms: None, + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: "1".to_string(), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "false".to_string(), + }], + stored_values: HashMap::new(), + error_text: None, + } + ); + } + + #[tokio::test] + async fn output_helpers_return_undefined() { + let service = CodeModeService::new(); + + let response = service + .execute(ExecuteRequest { + source: r#" +const returnsUndefined = [ + text("first"), + image("https://example.com/image.jpg"), + notify("ping"), +].map((value) => value === undefined); +text(JSON.stringify(returnsUndefined)); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: "1".to_string(), + content_items: vec![ + FunctionCallOutputContentItem::InputText { + text: "first".to_string(), + }, + FunctionCallOutputContentItem::InputImage { + image_url: "https://example.com/image.jpg".to_string(), + detail: None, + }, + FunctionCallOutputContentItem::InputText { + text: "[true,true,true]".to_string(), + }, + ], + stored_values: HashMap::new(), + error_text: None, + } + ); + } + + #[tokio::test] + async fn terminate_waits_for_runtime_shutdown_before_responding() { + let inner = test_inner(); + let (event_tx, event_rx) = mpsc::unbounded_channel(); + let (control_tx, control_rx) = mpsc::unbounded_channel(); + let (initial_response_tx, initial_response_rx) = oneshot::channel(); + let (runtime_event_tx, _runtime_event_rx) = mpsc::unbounded_channel(); + let (runtime_tx, runtime_terminate_handle) = spawn_runtime( + ExecuteRequest { + source: "await new Promise(() => {})".to_string(), + yield_time_ms: None, + ..execute_request("") + }, + runtime_event_tx, + ) + .unwrap(); + + tokio::spawn(run_session_control( + inner, + SessionControlContext { + cell_id: "cell-1".to_string(), + runtime_tx: runtime_tx.clone(), + runtime_terminate_handle, + }, + event_rx, + control_rx, + initial_response_tx, + 60_000, + )); + + event_tx.send(RuntimeEvent::Started).unwrap(); + event_tx.send(RuntimeEvent::YieldRequested).unwrap(); + assert_eq!( + initial_response_rx.await.unwrap(), + RuntimeResponse::Yielded { + cell_id: "cell-1".to_string(), + content_items: Vec::new(), + } + ); + + let (terminate_response_tx, terminate_response_rx) = oneshot::channel(); + control_tx + .send(SessionControlCommand::Terminate { + response_tx: terminate_response_tx, + }) + .unwrap(); + let terminate_response = async { terminate_response_rx.await.unwrap() }; + tokio::pin!(terminate_response); + assert!( + tokio::time::timeout(Duration::from_millis(100), terminate_response.as_mut()) + .await + .is_err() + ); + + drop(event_tx); + + assert_eq!( + terminate_response.await, + RuntimeResponse::Terminated { + cell_id: "cell-1".to_string(), + content_items: Vec::new(), + } + ); + + let _ = runtime_tx.send(RuntimeCommand::Terminate); + } +} diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index d648655b24..6386c8d424 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -31,6 +31,7 @@ codex-api = { workspace = true } codex-app-server-protocol = { workspace = true } codex-apply-patch = { workspace = true } codex-async-utils = { workspace = true } +codex-code-mode = { workspace = true } codex-connectors = { workspace = true } codex-config = { workspace = true } codex-exec-server = { workspace = true } diff --git a/codex-rs/core/README.md b/codex-rs/core/README.md index 3558fd9f60..c27ec5460e 100644 --- a/codex-rs/core/README.md +++ b/codex-rs/core/README.md @@ -61,9 +61,11 @@ cases like `/repo = write`, `/repo/a = none`, `/repo/a/b = write`, where the more specific writable child must reopen under a denied parent. The Linux sandbox helper prefers `/usr/bin/bwrap` whenever it is available and -falls back to the vendored bubblewrap path otherwise. When `/usr/bin/bwrap` is -missing, Codex also surfaces a startup warning through its normal notification -path instead of printing directly from the sandbox helper. +supports the required argv-rewrite flags, and falls back to the vendored +bubblewrap path compiled into the binary otherwise. When `/usr/bin/bwrap` is +missing or too old to support the required flags, Codex also surfaces a startup +warning through its normal notification path instead of printing directly from +the sandbox helper. ### Windows diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 48fa853556..278cedce5d 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -425,15 +425,15 @@ "multi_agent": { "type": "boolean" }, + "multi_agent_v2": { + "type": "boolean" + }, "personality": { "type": "boolean" }, "plugins": { "type": "boolean" }, - "powershell_utf8": { - "type": "boolean" - }, "prevent_idle_sleep": { "type": "boolean" }, @@ -2034,15 +2034,15 @@ "multi_agent": { "type": "boolean" }, + "multi_agent_v2": { + "type": "boolean" + }, "personality": { "type": "boolean" }, "plugins": { "type": "boolean" }, - "powershell_utf8": { - "type": "boolean" - }, "prevent_idle_sleep": { "type": "boolean" }, diff --git a/codex-rs/core/src/agent/agent_resolver.rs b/codex-rs/core/src/agent/agent_resolver.rs new file mode 100644 index 0000000000..3d1f75f57f --- /dev/null +++ b/codex-rs/core/src/agent/agent_resolver.rs @@ -0,0 +1,55 @@ +use crate::codex::Session; +use crate::codex::TurnContext; +use crate::function_tool::FunctionCallError; +use codex_protocol::ThreadId; +use std::sync::Arc; + +/// Resolves a single tool-facing agent target to a thread id. +pub(crate) async fn resolve_agent_target( + session: &Arc, + turn: &Arc, + target: &str, +) -> Result { + register_session_root(session, turn); + if let Ok(thread_id) = ThreadId::from_string(target) { + return Ok(thread_id); + } + + session + .services + .agent_control + .resolve_agent_reference(session.conversation_id, &turn.session_source, target) + .await + .map_err(|err| match err { + crate::error::CodexErr::UnsupportedOperation(message) => { + FunctionCallError::RespondToModel(message) + } + other => FunctionCallError::RespondToModel(other.to_string()), + }) +} + +/// Resolves multiple tool-facing agent targets to thread ids. +pub(crate) async fn resolve_agent_targets( + session: &Arc, + turn: &Arc, + targets: Vec, +) -> Result, FunctionCallError> { + if targets.is_empty() { + return Err(FunctionCallError::RespondToModel( + "agent targets must be non-empty".to_string(), + )); + } + + let mut resolved = Vec::with_capacity(targets.len()); + for target in &targets { + resolved.push(resolve_agent_target(session, turn, target).await?); + } + Ok(resolved) +} + +fn register_session_root(session: &Arc, turn: &Arc) { + session + .services + .agent_control + .register_session_root(session.conversation_id, &turn.session_source); +} diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index d75fc89525..5bc5a0711c 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -1,5 +1,8 @@ use crate::agent::AgentStatus; -use crate::agent::guards::Guards; +use crate::agent::inter_agent_instruction::InterAgentDelivery; +use crate::agent::inter_agent_instruction::InterAgentInstruction; +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; @@ -15,6 +18,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::state_db; use crate::thread_manager::ThreadManagerState; use codex_features::Feature; +use codex_protocol::AgentPath; use codex_protocol::ThreadId; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseItem; @@ -41,6 +45,13 @@ pub(crate) struct SpawnAgentOptions { pub(crate) fork_parent_spawn_call_id: Option, } +#[derive(Clone, Debug)] +pub(crate) struct LiveAgent { + pub(crate) thread_id: ThreadId, + pub(crate) metadata: AgentMetadata, + pub(crate) status: AgentStatus, +} + fn default_agent_nickname_list() -> Vec<&'static str> { AGENT_NAMES .lines() @@ -69,16 +80,16 @@ fn agent_nickname_candidates( /// Control-plane handle for multi-agent operations. /// `AgentControl` is held by each session (via `SessionServices`). It provides capability to /// spawn new agents and the inter-agent communication layer. -/// An `AgentControl` instance is shared per "user session" which means the same `AgentControl` -/// is used for every sub-agent spawned by Codex. By doing so, we make sure the guards are -/// scoped to a user session. +/// An `AgentControl` instance is intended to be created at most once per root thread/session +/// tree. That same `AgentControl` is then shared with every sub-agent spawned from that root, +/// which keeps the registry scoped to that root thread rather than the entire `ThreadManager`. #[derive(Clone, Default)] pub(crate) struct AgentControl { /// Weak handle back to the global thread registry/state. /// This is `Weak` to avoid reference cycles and shadow persistence of the form /// `ThreadManagerState -> CodexThread -> Session -> SessionServices -> ThreadManagerState`. manager: Weak, - state: Arc, + state: Arc, } impl AgentControl { @@ -97,17 +108,30 @@ impl AgentControl { items: Vec, session_source: Option, ) -> CodexResult { - self.spawn_agent_with_options(config, items, session_source, SpawnAgentOptions::default()) - .await + Ok(self + .spawn_agent_internal(config, items, session_source, SpawnAgentOptions::default()) + .await? + .thread_id) } - pub(crate) async fn spawn_agent_with_options( + pub(crate) async fn spawn_agent_with_metadata( &self, config: crate::config::Config, items: Vec, session_source: Option, options: SpawnAgentOptions, - ) -> CodexResult { + ) -> CodexResult { + self.spawn_agent_internal(config, items, session_source, options) + .await + } + + async fn spawn_agent_internal( + &self, + config: crate::config::Config, + items: Vec, + session_source: Option, + options: SpawnAgentOptions, + ) -> CodexResult { let state = self.upgrade()?; let mut reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?; let inherited_shell_snapshot = self @@ -116,25 +140,26 @@ impl AgentControl { let inherited_exec_policy = self .inherited_exec_policy_for_source(&state, session_source.as_ref(), &config) .await; - let session_source = match session_source { + let (session_source, mut agent_metadata) = match session_source { Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth, + agent_path, agent_role, .. })) => { - let candidate_names = agent_nickname_candidates(&config, agent_role.as_deref()); - let candidate_name_refs: Vec<&str> = - candidate_names.iter().map(String::as_str).collect(); - let agent_nickname = reservation.reserve_agent_nickname(&candidate_name_refs)?; - Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + let (session_source, agent_metadata) = self.prepare_thread_spawn( + &mut reservation, + &config, parent_thread_id, depth, - agent_nickname: Some(agent_nickname), + agent_path, agent_role, - })) + /*preferred_agent_nickname*/ None, + )?; + (Some(session_source), agent_metadata) } - other => other, + other => (other, AgentMetadata::default()), }; let notification_source = session_source.clone(); @@ -217,7 +242,8 @@ impl AgentControl { } None => state.spawn_new_thread(config, self.clone()).await?, }; - reservation.commit(new_thread.thread_id); + agent_metadata.agent_id = Some(new_thread.thread_id); + reservation.commit(agent_metadata.clone()); // Notify a new thread has been created. This notification will be processed by clients // to subscribe or drain this newly created thread. @@ -232,9 +258,22 @@ impl AgentControl { .await; self.send_input(new_thread.thread_id, items).await?; - self.maybe_start_completion_watcher(new_thread.thread_id, notification_source); + 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, + ); - Ok(new_thread.thread_id) + Ok(LiveAgent { + thread_id: new_thread.thread_id, + metadata: agent_metadata, + status: self.get_status(new_thread.thread_id).await, + }) } /// Resume an existing agent thread from a recorded rollout file. @@ -283,6 +322,7 @@ impl AgentControl { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth: child_depth, + agent_path: None, agent_nickname: None, agent_role: None, }); @@ -324,14 +364,14 @@ impl AgentControl { } let state = self.upgrade()?; let mut reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?; - let session_source = match session_source { + let (session_source, agent_metadata) = match session_source { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth, - .. + agent_path, + agent_role: _, + agent_nickname: _, }) => { - // Collab resume callers rebuild a placeholder ThreadSpawn source. Rehydrate the - // stored nickname/role from sqlite when available; otherwise leave both unset. let (resumed_agent_nickname, resumed_agent_role) = if let Some(state_db_ctx) = state_db::get_state_db(&config).await { match state_db_ctx.get_thread(thread_id).await { @@ -341,27 +381,17 @@ impl AgentControl { } else { (None, None) }; - let reserved_agent_nickname = resumed_agent_nickname - .as_deref() - .map(|agent_nickname| { - let candidate_names = - agent_nickname_candidates(&config, resumed_agent_role.as_deref()); - let candidate_name_refs: Vec<&str> = - candidate_names.iter().map(String::as_str).collect(); - reservation.reserve_agent_nickname_with_preference( - &candidate_name_refs, - Some(agent_nickname), - ) - }) - .transpose()?; - SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + self.prepare_thread_spawn( + &mut reservation, + &config, parent_thread_id, depth, - agent_nickname: reserved_agent_nickname, - agent_role: resumed_agent_role, - }) + agent_path, + resumed_agent_role, + resumed_agent_nickname, + )? } - other => other, + other => (other, AgentMetadata::default()), }; let notification_source = session_source.clone(); let inherited_shell_snapshot = self @@ -393,13 +423,21 @@ impl AgentControl { inherited_exec_policy, ) .await?; - reservation.commit(resumed_thread.thread_id); + let mut agent_metadata = agent_metadata; + agent_metadata.agent_id = Some(resumed_thread.thread_id); + reservation.commit(agent_metadata.clone()); // 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, ); self.persist_thread_spawn_edge_for_source( resumed_thread.thread.as_ref(), @@ -418,20 +456,53 @@ impl AgentControl { items: Vec, ) -> CodexResult { let state = self.upgrade()?; - let result = state - .send_op( - agent_id, - Op::UserInput { - items, - final_output_json_schema: None, - }, - ) - .await; - if matches!(result, Err(CodexErr::InternalAgentDied)) { - let _ = state.remove_thread(&agent_id).await; - self.state.release_spawned_thread(agent_id); - } - result + self.handle_thread_request_result( + agent_id, + &state, + state + .send_op( + agent_id, + Op::UserInput { + items, + final_output_json_schema: None, + }, + ) + .await, + ) + .await + } + + /// Append a prebuilt message to an existing agent thread outside the normal user-input path. + #[cfg(test)] + pub(crate) async fn append_message( + &self, + agent_id: ThreadId, + message: ResponseItem, + ) -> CodexResult { + let state = self.upgrade()?; + self.handle_thread_request_result( + agent_id, + &state, + state.append_message(agent_id, message).await, + ) + .await + } + + pub(crate) async fn deliver_inter_agent_instruction( + &self, + agent_id: ThreadId, + instruction: InterAgentInstruction, + delivery: InterAgentDelivery, + ) -> CodexResult { + let state = self.upgrade()?; + self.handle_thread_request_result( + agent_id, + &state, + state + .deliver_inter_agent_instruction(agent_id, instruction, delivery) + .await, + ) + .await } /// Interrupt the current task for an existing agent thread. @@ -440,6 +511,19 @@ impl AgentControl { state.send_op(agent_id, Op::Interrupt).await } + async fn handle_thread_request_result( + &self, + agent_id: ThreadId, + state: &Arc, + result: CodexResult, + ) -> CodexResult { + if matches!(result, Err(CodexErr::InternalAgentDied)) { + let _ = state.remove_thread(&agent_id).await; + self.state.release_spawned_thread(agent_id); + } + result + } + /// Submit a shutdown request for a live agent without marking it explicitly closed in /// persisted spawn-edge state. pub(crate) async fn shutdown_live_agent(&self, agent_id: ThreadId) -> CodexResult { @@ -500,21 +584,18 @@ impl AgentControl { thread.agent_status().await } - pub(crate) async fn get_agent_nickname_and_role( + pub(crate) fn register_session_root( &self, - agent_id: ThreadId, - ) -> Option<(Option, Option)> { - let Ok(state) = self.upgrade() else { - return None; - }; - let Ok(thread) = state.get_thread(agent_id).await else { - return None; - }; - let session_source = thread.config_snapshot().await.session_source; - Some(( - session_source.get_nickname(), - session_source.get_agent_role(), - )) + current_thread_id: ThreadId, + current_session_source: &SessionSource, + ) { + if thread_spawn_parent_thread_id(current_session_source).is_none() { + self.state.register_root_thread(current_thread_id); + } + } + + pub(crate) fn get_agent_metadata(&self, agent_id: ThreadId) -> Option { + self.state.agent_metadata_for_thread(agent_id) } pub(crate) async fn get_agent_config_snapshot( @@ -530,6 +611,33 @@ impl AgentControl { Some(thread.config_snapshot().await) } + pub(crate) async fn resolve_agent_reference( + &self, + _current_thread_id: ThreadId, + current_session_source: &SessionSource, + agent_reference: &str, + ) -> CodexResult { + let current_agent_path = current_session_source + .get_agent_path() + .unwrap_or_else(AgentPath::root); + let agent_path = current_agent_path + .resolve(agent_reference) + .map_err(CodexErr::UnsupportedOperation)?; + if agent_path.is_root() { + return Err(CodexErr::UnsupportedOperation( + "root is not a spawned agent".to_string(), + )); + } + + if let Some(thread_id) = self.state.agent_id_for_path(&agent_path) { + return Ok(thread_id); + } + Err(CodexErr::UnsupportedOperation(format!( + "live agent path `{}` not found", + agent_path.as_str() + ))) + } + /// Subscribe to status updates for `agent_id`, yielding the latest value and changes. pub(crate) async fn subscribe_status( &self, @@ -560,8 +668,13 @@ impl AgentControl { agents .into_iter() - .map(|(thread_id, nickname)| { - format_subagent_context_line(&thread_id.to_string(), nickname.as_deref()) + .map(|(thread_id, metadata)| { + let reference = metadata + .agent_path + .as_ref() + .map(|agent_path| agent_path.name().to_string()) + .unwrap_or_else(|| thread_id.to_string()); + format_subagent_context_line(reference.as_str(), metadata.agent_nickname.as_deref()) }) .collect::>() .join("\n") @@ -575,6 +688,7 @@ impl AgentControl { &self, child_thread_id: ThreadId, session_source: Option, + child_reference: String, ) { let Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, .. @@ -610,13 +724,52 @@ impl AgentControl { }; parent_thread .inject_user_message_without_turn(format_subagent_notification_message( - &child_thread_id.to_string(), + child_reference.as_str(), &status, )) .await; }); } + #[allow(clippy::too_many_arguments)] + fn prepare_thread_spawn( + &self, + reservation: &mut crate::agent::registry::SpawnReservation, + config: &crate::config::Config, + parent_thread_id: ThreadId, + depth: i32, + agent_path: Option, + agent_role: Option, + preferred_agent_nickname: Option, + ) -> CodexResult<(SessionSource, AgentMetadata)> { + if depth == 1 { + self.state.register_root_thread(parent_thread_id); + } + if let Some(agent_path) = agent_path.as_ref() { + reservation.reserve_agent_path(agent_path)?; + } + let candidate_names = agent_nickname_candidates(config, agent_role.as_deref()); + let candidate_name_refs: Vec<&str> = candidate_names.iter().map(String::as_str).collect(); + let agent_nickname = Some(reservation.reserve_agent_nickname_with_preference( + &candidate_name_refs, + preferred_agent_nickname.as_deref(), + )?); + let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth, + agent_path: agent_path.clone(), + agent_nickname: agent_nickname.clone(), + agent_role: agent_role.clone(), + }); + let agent_metadata = AgentMetadata { + agent_id: None, + agent_path, + agent_nickname, + agent_role, + }; + Ok((session_source, agent_metadata)) + } + fn upgrade(&self) -> CodexResult> { self.manager .upgrade() @@ -666,7 +819,7 @@ impl AgentControl { async fn open_thread_spawn_children( &self, parent_thread_id: ThreadId, - ) -> CodexResult)>> { + ) -> CodexResult> { let mut children_by_parent = self.live_thread_spawn_children().await?; Ok(children_by_parent .remove(&parent_thread_id) @@ -675,9 +828,9 @@ impl AgentControl { async fn live_thread_spawn_children( &self, - ) -> CodexResult)>>> { + ) -> CodexResult>> { let state = self.upgrade()?; - let mut children_by_parent = HashMap::)>>::new(); + let mut children_by_parent = HashMap::>::new(); for thread_id in state.list_thread_ids().await { let Ok(thread) = state.get_thread(thread_id).await else { @@ -691,11 +844,26 @@ impl AgentControl { children_by_parent .entry(parent_thread_id) .or_default() - .push((thread_id, snapshot.session_source.get_nickname())); + .push(( + thread_id, + self.state + .agent_metadata_for_thread(thread_id) + .unwrap_or(AgentMetadata { + agent_id: Some(thread_id), + ..Default::default() + }), + )); } for children in children_by_parent.values_mut() { - children.sort_by(|left, right| left.0.to_string().cmp(&right.0.to_string())); + children.sort_by(|left, right| { + left.1 + .agent_path + .as_deref() + .unwrap_or_default() + .cmp(right.1.agent_path.as_deref().unwrap_or_default()) + .then_with(|| left.0.to_string().cmp(&right.0.to_string())) + }); } Ok(children_by_parent) diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 24344db719..677bf5a2f3 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -382,6 +382,61 @@ async fn send_input_submits_user_message() { assert_eq!(captured, Some(expected)); } +#[tokio::test] +async fn append_message_records_assistant_message() { + let harness = AgentControlHarness::new().await; + let (thread_id, thread) = harness.start_thread().await; + let message = + "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: hello from tests"; + + let submission_id = harness + .control + .append_message( + thread_id, + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::InputText { + text: message.to_string(), + }], + end_turn: None, + phase: None, + }, + ) + .await + .expect("append_message should succeed"); + assert!(!submission_id.is_empty()); + + timeout(Duration::from_secs(5), async { + loop { + let history_items = thread + .codex + .session + .clone_history() + .await + .raw_items() + .to_vec(); + let recorded = history_items.iter().any(|item| { + matches!( + item, + ResponseItem::Message { role, content, .. } + if role == "assistant" + && content.iter().any(|content_item| matches!( + content_item, + ContentItem::InputText { text } if text == message + )) + ) + }); + if recorded { + break; + } + sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("assistant message should be recorded"); +} + #[tokio::test] async fn spawn_agent_creates_thread_and_sends_prompt() { let harness = AgentControlHarness::new().await; @@ -443,12 +498,13 @@ async fn spawn_agent_can_fork_parent_thread_history() { let child_thread_id = harness .control - .spawn_agent_with_options( + .spawn_agent_with_metadata( harness.config.clone(), text_input("child task"), Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth: 1, + agent_path: None, agent_nickname: None, agent_role: None, })), @@ -457,7 +513,8 @@ async fn spawn_agent_can_fork_parent_thread_history() { }, ) .await - .expect("forked spawn should succeed"); + .expect("forked spawn should succeed") + .thread_id; let child_thread = harness .manager @@ -526,12 +583,13 @@ async fn spawn_agent_fork_injects_output_for_parent_spawn_call() { let child_thread_id = harness .control - .spawn_agent_with_options( + .spawn_agent_with_metadata( harness.config.clone(), text_input("child task"), Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth: 1, + agent_path: None, agent_nickname: None, agent_role: None, })), @@ -540,7 +598,8 @@ async fn spawn_agent_fork_injects_output_for_parent_spawn_call() { }, ) .await - .expect("forked spawn should succeed"); + .expect("forked spawn should succeed") + .thread_id; let child_thread = harness .manager @@ -596,12 +655,13 @@ async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() { let child_thread_id = harness .control - .spawn_agent_with_options( + .spawn_agent_with_metadata( harness.config.clone(), text_input("child task"), Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth: 1, + agent_path: None, agent_nickname: None, agent_role: None, })), @@ -610,7 +670,8 @@ async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() { }, ) .await - .expect("forked spawn should flush parent rollout before loading history"); + .expect("forked spawn should flush parent rollout before loading history") + .thread_id; let child_thread = harness .manager @@ -855,6 +916,7 @@ async fn spawn_child_completion_notifies_parent_history() { Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth: 1, + agent_path: None, agent_nickname: None, agent_role: Some("explorer".to_string()), })), @@ -886,9 +948,11 @@ async fn completion_watcher_notifies_parent_when_child_is_missing() { 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(), ); assert_eq!(wait_for_subagent_notification(&parent_thread).await, true); @@ -903,7 +967,7 @@ async fn completion_watcher_notifies_parent_when_child_is_missing() { assert_eq!( history_contains_text( &history_items, - &format!("\"agent_id\":\"{child_thread_id}\"") + &format!("\"agent_path\":\"{child_thread_id}\"") ), true ); @@ -926,6 +990,7 @@ async fn spawn_thread_subagent_gets_random_nickname_in_session_source() { Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth: 1, + agent_path: None, agent_nickname: None, agent_role: Some("explorer".to_string()), })), @@ -945,6 +1010,7 @@ async fn spawn_thread_subagent_gets_random_nickname_in_session_source() { depth, agent_nickname, agent_role, + .. }) = snapshot.session_source else { panic!("expected thread-spawn sub-agent source"); @@ -976,6 +1042,7 @@ async fn spawn_thread_subagent_uses_role_specific_nickname_candidates() { Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth: 1, + agent_path: None, agent_nickname: None, agent_role: Some("researcher".to_string()), })), @@ -1018,6 +1085,8 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() { control, }; let (parent_thread_id, _parent_thread) = harness.start_thread().await; + let agent_path = AgentPath::from_string("/root/explorer".to_string()) + .expect("test agent path should be valid"); let child_thread_id = harness .control @@ -1027,6 +1096,7 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() { Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth: 1, + agent_path: Some(agent_path.clone()), agent_nickname: None, agent_role: Some("explorer".to_string()), })), @@ -1095,6 +1165,7 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth: 1, + agent_path: Some(agent_path.clone()), agent_nickname: None, agent_role: None, }), @@ -1113,14 +1184,17 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() { let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: resumed_parent_thread_id, depth: resumed_depth, + agent_path: resumed_agent_path, agent_nickname: resumed_nickname, agent_role: resumed_role, + .. }) = resumed_snapshot.session_source else { panic!("expected thread-spawn sub-agent source"); }; assert_eq!(resumed_parent_thread_id, parent_thread_id); assert_eq!(resumed_depth, 1); + assert_eq!(resumed_agent_path, Some(agent_path)); assert_eq!(resumed_nickname, Some(original_nickname)); assert_eq!(resumed_role, Some("explorer".to_string())); @@ -1206,6 +1280,7 @@ async fn shutdown_agent_tree_closes_live_descendants() { Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth: 1, + agent_path: None, agent_nickname: None, agent_role: Some("explorer".to_string()), })), @@ -1220,6 +1295,7 @@ async fn shutdown_agent_tree_closes_live_descendants() { Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: child_thread_id, depth: 2, + agent_path: None, agent_nickname: None, agent_role: Some("worker".to_string()), })), @@ -1289,6 +1365,7 @@ async fn shutdown_agent_tree_closes_descendants_when_started_at_child() { Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth: 1, + agent_path: None, agent_nickname: None, agent_role: Some("explorer".to_string()), })), @@ -1303,6 +1380,7 @@ async fn shutdown_agent_tree_closes_descendants_when_started_at_child() { Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: child_thread_id, depth: 2, + agent_path: None, agent_nickname: None, agent_role: Some("worker".to_string()), })), @@ -1378,6 +1456,7 @@ async fn resume_agent_from_rollout_does_not_reopen_closed_descendants() { Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth: 1, + agent_path: None, agent_nickname: None, agent_role: Some("explorer".to_string()), })), @@ -1392,6 +1471,7 @@ async fn resume_agent_from_rollout_does_not_reopen_closed_descendants() { Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: child_thread_id, depth: 2, + agent_path: None, agent_nickname: None, agent_role: Some("worker".to_string()), })), @@ -1471,6 +1551,7 @@ async fn resume_closed_child_reopens_open_descendants() { Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth: 1, + agent_path: None, agent_nickname: None, agent_role: Some("explorer".to_string()), })), @@ -1485,6 +1566,7 @@ async fn resume_closed_child_reopens_open_descendants() { Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: child_thread_id, depth: 2, + agent_path: None, agent_nickname: None, agent_role: Some("worker".to_string()), })), @@ -1524,6 +1606,7 @@ async fn resume_closed_child_reopens_open_descendants() { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth: 1, + agent_path: None, agent_nickname: None, agent_role: None, }), @@ -1565,6 +1648,7 @@ async fn resume_agent_from_rollout_reopens_open_descendants_after_manager_shutdo Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth: 1, + agent_path: None, agent_nickname: None, agent_role: Some("explorer".to_string()), })), @@ -1579,6 +1663,7 @@ async fn resume_agent_from_rollout_reopens_open_descendants_after_manager_shutdo Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: child_thread_id, depth: 2, + agent_path: None, agent_nickname: None, agent_role: Some("worker".to_string()), })), @@ -1654,6 +1739,7 @@ async fn resume_agent_from_rollout_uses_edge_data_when_descendant_metadata_sourc Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth: 1, + agent_path: None, agent_nickname: None, agent_role: Some("explorer".to_string()), })), @@ -1668,6 +1754,7 @@ async fn resume_agent_from_rollout_uses_edge_data_when_descendant_metadata_sourc Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: child_thread_id, depth: 2, + agent_path: None, agent_nickname: None, agent_role: Some("worker".to_string()), })), @@ -1705,6 +1792,7 @@ async fn resume_agent_from_rollout_uses_edge_data_when_descendant_metadata_sourc serde_json::to_string(&SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: ThreadId::new(), depth: 99, + agent_path: None, agent_nickname: None, agent_role: Some("worker".to_string()), })) @@ -1782,6 +1870,7 @@ async fn resume_agent_from_rollout_skips_descendants_when_parent_resume_fails() Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth: 1, + agent_path: None, agent_nickname: None, agent_role: Some("explorer".to_string()), })), @@ -1796,6 +1885,7 @@ async fn resume_agent_from_rollout_skips_descendants_when_parent_resume_fails() Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: child_thread_id, depth: 2, + agent_path: None, agent_nickname: None, agent_role: Some("worker".to_string()), })), diff --git a/codex-rs/core/src/agent/guards_tests.rs b/codex-rs/core/src/agent/guards_tests.rs deleted file mode 100644 index 53bb5f3b30..0000000000 --- a/codex-rs/core/src/agent/guards_tests.rs +++ /dev/null @@ -1,243 +0,0 @@ -use super::*; -use pretty_assertions::assert_eq; -use std::collections::HashSet; - -#[test] -fn format_agent_nickname_adds_ordinals_after_reset() { - assert_eq!(format_agent_nickname("Plato", 0), "Plato"); - assert_eq!(format_agent_nickname("Plato", 1), "Plato the 2nd"); - assert_eq!(format_agent_nickname("Plato", 2), "Plato the 3rd"); - assert_eq!(format_agent_nickname("Plato", 10), "Plato the 11th"); - assert_eq!(format_agent_nickname("Plato", 20), "Plato the 21st"); -} - -#[test] -fn session_depth_defaults_to_zero_for_root_sources() { - assert_eq!(session_depth(&SessionSource::Cli), 0); -} - -#[test] -fn thread_spawn_depth_increments_and_enforces_limit() { - let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id: ThreadId::new(), - depth: 1, - agent_nickname: None, - agent_role: None, - }); - let child_depth = next_thread_spawn_depth(&session_source); - assert_eq!(child_depth, 2); - assert!(exceeds_thread_spawn_depth_limit(child_depth, 1)); -} - -#[test] -fn non_thread_spawn_subagents_default_to_depth_zero() { - let session_source = SessionSource::SubAgent(SubAgentSource::Review); - assert_eq!(session_depth(&session_source), 0); - assert_eq!(next_thread_spawn_depth(&session_source), 1); - assert!(!exceeds_thread_spawn_depth_limit(1, 1)); -} - -#[test] -fn reservation_drop_releases_slot() { - let guards = Arc::new(Guards::default()); - let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot"); - drop(reservation); - - let reservation = guards.reserve_spawn_slot(Some(1)).expect("slot released"); - drop(reservation); -} - -#[test] -fn commit_holds_slot_until_release() { - let guards = Arc::new(Guards::default()); - let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot"); - let thread_id = ThreadId::new(); - reservation.commit(thread_id); - - let err = match guards.reserve_spawn_slot(Some(1)) { - Ok(_) => panic!("limit should be enforced"), - Err(err) => err, - }; - let CodexErr::AgentLimitReached { max_threads } = err else { - panic!("expected CodexErr::AgentLimitReached"); - }; - assert_eq!(max_threads, 1); - - guards.release_spawned_thread(thread_id); - let reservation = guards - .reserve_spawn_slot(Some(1)) - .expect("slot released after thread removal"); - drop(reservation); -} - -#[test] -fn release_ignores_unknown_thread_id() { - let guards = Arc::new(Guards::default()); - let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot"); - let thread_id = ThreadId::new(); - reservation.commit(thread_id); - - guards.release_spawned_thread(ThreadId::new()); - - let err = match guards.reserve_spawn_slot(Some(1)) { - Ok(_) => panic!("limit should still be enforced"), - Err(err) => err, - }; - let CodexErr::AgentLimitReached { max_threads } = err else { - panic!("expected CodexErr::AgentLimitReached"); - }; - assert_eq!(max_threads, 1); - - guards.release_spawned_thread(thread_id); - let reservation = guards - .reserve_spawn_slot(Some(1)) - .expect("slot released after real thread removal"); - drop(reservation); -} - -#[test] -fn release_is_idempotent_for_registered_threads() { - let guards = Arc::new(Guards::default()); - let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot"); - let first_id = ThreadId::new(); - reservation.commit(first_id); - - guards.release_spawned_thread(first_id); - - let reservation = guards.reserve_spawn_slot(Some(1)).expect("slot reused"); - let second_id = ThreadId::new(); - reservation.commit(second_id); - - guards.release_spawned_thread(first_id); - - let err = match guards.reserve_spawn_slot(Some(1)) { - Ok(_) => panic!("limit should still be enforced"), - Err(err) => err, - }; - let CodexErr::AgentLimitReached { max_threads } = err else { - panic!("expected CodexErr::AgentLimitReached"); - }; - assert_eq!(max_threads, 1); - - guards.release_spawned_thread(second_id); - let reservation = guards - .reserve_spawn_slot(Some(1)) - .expect("slot released after second thread removal"); - drop(reservation); -} - -#[test] -fn failed_spawn_keeps_nickname_marked_used() { - let guards = Arc::new(Guards::default()); - let mut reservation = guards.reserve_spawn_slot(None).expect("reserve slot"); - let agent_nickname = reservation - .reserve_agent_nickname(&["alpha"]) - .expect("reserve agent name"); - assert_eq!(agent_nickname, "alpha"); - drop(reservation); - - let mut reservation = guards.reserve_spawn_slot(None).expect("reserve slot"); - let agent_nickname = reservation - .reserve_agent_nickname(&["alpha", "beta"]) - .expect("unused name should still be preferred"); - assert_eq!(agent_nickname, "beta"); -} - -#[test] -fn agent_nickname_resets_used_pool_when_exhausted() { - let guards = Arc::new(Guards::default()); - let mut first = guards.reserve_spawn_slot(None).expect("reserve first slot"); - let first_name = first - .reserve_agent_nickname(&["alpha"]) - .expect("reserve first agent name"); - let first_id = ThreadId::new(); - first.commit(first_id); - assert_eq!(first_name, "alpha"); - - let mut second = guards - .reserve_spawn_slot(None) - .expect("reserve second slot"); - let second_name = second - .reserve_agent_nickname(&["alpha"]) - .expect("name should be reused after pool reset"); - assert_eq!(second_name, "alpha the 2nd"); - let active_agents = guards - .active_agents - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - assert_eq!(active_agents.nickname_reset_count, 1); -} - -#[test] -fn released_nickname_stays_used_until_pool_reset() { - let guards = Arc::new(Guards::default()); - - let mut first = guards.reserve_spawn_slot(None).expect("reserve first slot"); - let first_name = first - .reserve_agent_nickname(&["alpha"]) - .expect("reserve first agent name"); - let first_id = ThreadId::new(); - first.commit(first_id); - assert_eq!(first_name, "alpha"); - - guards.release_spawned_thread(first_id); - - let mut second = guards - .reserve_spawn_slot(None) - .expect("reserve second slot"); - let second_name = second - .reserve_agent_nickname(&["alpha", "beta"]) - .expect("released name should still be marked used"); - assert_eq!(second_name, "beta"); - let second_id = ThreadId::new(); - second.commit(second_id); - guards.release_spawned_thread(second_id); - - let mut third = guards.reserve_spawn_slot(None).expect("reserve third slot"); - let third_name = third - .reserve_agent_nickname(&["alpha", "beta"]) - .expect("pool reset should permit a duplicate"); - let expected_names = HashSet::from(["alpha the 2nd".to_string(), "beta the 2nd".to_string()]); - assert!(expected_names.contains(&third_name)); - let active_agents = guards - .active_agents - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - assert_eq!(active_agents.nickname_reset_count, 1); -} - -#[test] -fn repeated_resets_advance_the_ordinal_suffix() { - let guards = Arc::new(Guards::default()); - - let mut first = guards.reserve_spawn_slot(None).expect("reserve first slot"); - let first_name = first - .reserve_agent_nickname(&["Plato"]) - .expect("reserve first agent name"); - let first_id = ThreadId::new(); - first.commit(first_id); - assert_eq!(first_name, "Plato"); - guards.release_spawned_thread(first_id); - - let mut second = guards - .reserve_spawn_slot(None) - .expect("reserve second slot"); - let second_name = second - .reserve_agent_nickname(&["Plato"]) - .expect("reserve second agent name"); - let second_id = ThreadId::new(); - second.commit(second_id); - assert_eq!(second_name, "Plato the 2nd"); - guards.release_spawned_thread(second_id); - - let mut third = guards.reserve_spawn_slot(None).expect("reserve third slot"); - let third_name = third - .reserve_agent_nickname(&["Plato"]) - .expect("reserve third agent name"); - assert_eq!(third_name, "Plato the 3rd"); - let active_agents = guards - .active_agents - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - assert_eq!(active_agents.nickname_reset_count, 2); -} diff --git a/codex-rs/core/src/agent/inter_agent_instruction.rs b/codex-rs/core/src/agent/inter_agent_instruction.rs new file mode 100644 index 0000000000..0eff40beb4 --- /dev/null +++ b/codex-rs/core/src/agent/inter_agent_instruction.rs @@ -0,0 +1,74 @@ +use codex_protocol::AgentPath; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum InterAgentDelivery { + CurrentTurn, + NextTurn, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct InterAgentInstruction { + author: AgentPath, + recipient: AgentPath, + other_recipients: Vec, + content: String, +} + +impl InterAgentInstruction { + pub(crate) fn new( + author: AgentPath, + recipient: AgentPath, + other_recipients: Vec, + content: String, + ) -> Self { + Self { + author, + recipient, + other_recipients, + content, + } + } + + pub(crate) fn to_response_item(&self) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: self.as_text(), + }], + end_turn: None, + phase: None, + } + } + + pub(crate) fn is_message_content(content: &[ContentItem]) -> bool { + content.iter().any(|content_item| match content_item { + ContentItem::InputText { text } | ContentItem::OutputText { text } => { + Self::is_instruction_text(text) + } + _ => false, + }) + } + + fn as_text(&self) -> String { + let other_recipients = self + .other_recipients + .iter() + .map(std::string::ToString::to_string) + .collect::>() + .join(", "); + format!( + "author: {}\nrecipient: {}\nother_recipients: [{other_recipients}]\nContent: {}", + self.author, self.recipient, self.content + ) + } + + fn is_instruction_text(text: &str) -> bool { + text.starts_with("author: ") + && text.contains("\nrecipient: ") + && text.contains("\nother_recipients: [") + && text.contains("]\nContent: ") + } +} diff --git a/codex-rs/core/src/agent/mod.rs b/codex-rs/core/src/agent/mod.rs index 15be909c3d..3f14e5c590 100644 --- a/codex-rs/core/src/agent/mod.rs +++ b/codex-rs/core/src/agent/mod.rs @@ -1,10 +1,12 @@ +pub(crate) mod agent_resolver; pub(crate) mod control; -mod guards; +pub(crate) mod inter_agent_instruction; +mod registry; pub(crate) mod role; pub(crate) mod status; pub(crate) use codex_protocol::protocol::AgentStatus; pub(crate) use control::AgentControl; -pub(crate) use guards::exceeds_thread_spawn_depth_limit; -pub(crate) use guards::next_thread_spawn_depth; +pub(crate) use registry::exceeds_thread_spawn_depth_limit; +pub(crate) use registry::next_thread_spawn_depth; pub(crate) use status::agent_status_from_event; diff --git a/codex-rs/core/src/agent/guards.rs b/codex-rs/core/src/agent/registry.rs similarity index 57% rename from codex-rs/core/src/agent/guards.rs rename to codex-rs/core/src/agent/registry.rs index 12fdc0aebe..af545e8c97 100644 --- a/codex-rs/core/src/agent/guards.rs +++ b/codex-rs/core/src/agent/registry.rs @@ -1,11 +1,13 @@ use crate::error::CodexErr; use crate::error::Result; +use codex_protocol::AgentPath; use codex_protocol::ThreadId; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use rand::prelude::IndexedRandom; use std::collections::HashMap; use std::collections::HashSet; +use std::collections::hash_map::Entry; use std::sync::Arc; use std::sync::Mutex; use std::sync::atomic::AtomicUsize; @@ -18,19 +20,26 @@ use std::sync::atomic::Ordering; /// This structure is shared by all agents in the same user session (because the `AgentControl` /// is). #[derive(Default)] -pub(crate) struct Guards { +pub(crate) struct AgentRegistry { active_agents: Mutex, total_count: AtomicUsize, } #[derive(Default)] struct ActiveAgents { - threads_set: HashSet, - thread_agent_nicknames: HashMap, + agent_tree: HashMap, used_agent_nicknames: HashSet, nickname_reset_count: usize, } +#[derive(Clone, Debug, Default)] +pub(crate) struct AgentMetadata { + pub(crate) agent_id: Option, + pub(crate) agent_path: Option, + pub(crate) agent_nickname: Option, + pub(crate) agent_role: Option, +} + fn format_agent_nickname(name: &str, nickname_reset_count: usize) -> String { match nickname_reset_count { 0 => name.to_string(), @@ -66,7 +75,7 @@ pub(crate) fn exceeds_thread_spawn_depth_limit(depth: i32, max_depth: i32) -> bo depth > max_depth } -impl Guards { +impl AgentRegistry { pub(crate) fn reserve_spawn_slot( self: &Arc, max_threads: Option, @@ -82,38 +91,83 @@ impl Guards { state: Arc::clone(self), active: true, reserved_agent_nickname: None, + reserved_agent_path: None, }) } pub(crate) fn release_spawned_thread(&self, thread_id: ThreadId) { - let removed = { + let removed_counted_agent = { let mut active_agents = self .active_agents .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - let removed = active_agents.threads_set.remove(&thread_id); - active_agents.thread_agent_nicknames.remove(&thread_id); - removed + let removed_key = active_agents + .agent_tree + .iter() + .find_map(|(key, metadata)| (metadata.agent_id == Some(thread_id)).then_some(key)) + .cloned(); + removed_key + .and_then(|key| active_agents.agent_tree.remove(key.as_str())) + .is_some_and(|metadata| { + !metadata.agent_path.as_ref().is_some_and(AgentPath::is_root) + }) }; - if removed { + if removed_counted_agent { self.total_count.fetch_sub(1, Ordering::AcqRel); } } - fn register_spawned_thread(&self, thread_id: ThreadId, agent_nickname: Option) { + pub(crate) fn register_root_thread(&self, thread_id: ThreadId) { let mut active_agents = self .active_agents .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - active_agents.threads_set.insert(thread_id); - if let Some(agent_nickname) = agent_nickname { - active_agents - .used_agent_nicknames - .insert(agent_nickname.clone()); - active_agents - .thread_agent_nicknames - .insert(thread_id, agent_nickname); + active_agents + .agent_tree + .entry(AgentPath::ROOT.to_string()) + .or_insert_with(|| AgentMetadata { + agent_id: Some(thread_id), + agent_path: Some(AgentPath::root()), + ..Default::default() + }); + } + + pub(crate) fn agent_id_for_path(&self, agent_path: &AgentPath) -> Option { + self.active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .agent_tree + .get(agent_path.as_str()) + .and_then(|metadata| metadata.agent_id) + } + + pub(crate) fn agent_metadata_for_thread(&self, thread_id: ThreadId) -> Option { + self.active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .agent_tree + .values() + .find(|metadata| metadata.agent_id == Some(thread_id)) + .cloned() + } + + fn register_spawned_thread(&self, agent_metadata: AgentMetadata) { + let Some(thread_id) = agent_metadata.agent_id else { + return; + }; + let mut active_agents = self + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let key = agent_metadata + .agent_path + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| format!("thread:{thread_id}")); + if let Some(agent_nickname) = agent_metadata.agent_nickname.clone() { + active_agents.used_agent_nicknames.insert(agent_nickname); } + active_agents.agent_tree.insert(key, agent_metadata); } fn reserve_agent_nickname(&self, names: &[&str], preferred: Option<&str>) -> Option { @@ -156,6 +210,39 @@ impl Guards { Some(agent_nickname) } + fn reserve_agent_path(&self, agent_path: &AgentPath) -> Result<()> { + let mut active_agents = self + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match active_agents.agent_tree.entry(agent_path.to_string()) { + Entry::Occupied(_) => Err(CodexErr::UnsupportedOperation(format!( + "agent path `{agent_path}` already exists" + ))), + Entry::Vacant(entry) => { + entry.insert(AgentMetadata { + agent_path: Some(agent_path.clone()), + ..Default::default() + }); + Ok(()) + } + } + } + + fn release_reserved_agent_path(&self, agent_path: &AgentPath) { + let mut active_agents = self + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if active_agents + .agent_tree + .get(agent_path.as_str()) + .is_some_and(|metadata| metadata.agent_id.is_none()) + { + active_agents.agent_tree.remove(agent_path.as_str()); + } + } + fn try_increment_spawned(&self, max_threads: usize) -> bool { let mut current = self.total_count.load(Ordering::Acquire); loop { @@ -176,16 +263,13 @@ impl Guards { } pub(crate) struct SpawnReservation { - state: Arc, + state: Arc, active: bool, reserved_agent_nickname: Option, + reserved_agent_path: Option, } impl SpawnReservation { - pub(crate) fn reserve_agent_nickname(&mut self, names: &[&str]) -> Result { - self.reserve_agent_nickname_with_preference(names, /*preferred*/ None) - } - pub(crate) fn reserve_agent_nickname_with_preference( &mut self, names: &[&str], @@ -201,18 +285,16 @@ impl SpawnReservation { Ok(agent_nickname) } - pub(crate) fn commit(self, thread_id: ThreadId) { - self.commit_with_agent_nickname(thread_id, /*agent_nickname*/ None); + pub(crate) fn reserve_agent_path(&mut self, agent_path: &AgentPath) -> Result<()> { + self.state.reserve_agent_path(agent_path)?; + self.reserved_agent_path = Some(agent_path.clone()); + Ok(()) } - pub(crate) fn commit_with_agent_nickname( - mut self, - thread_id: ThreadId, - agent_nickname: Option, - ) { - let agent_nickname = self.reserved_agent_nickname.take().or(agent_nickname); - self.state - .register_spawned_thread(thread_id, agent_nickname); + pub(crate) fn commit(mut self, agent_metadata: AgentMetadata) { + self.reserved_agent_nickname = None; + self.reserved_agent_path = None; + self.state.register_spawned_thread(agent_metadata); self.active = false; } } @@ -220,11 +302,14 @@ impl SpawnReservation { impl Drop for SpawnReservation { fn drop(&mut self) { if self.active { + if let Some(agent_path) = self.reserved_agent_path.take() { + self.state.release_reserved_agent_path(&agent_path); + } self.state.total_count.fetch_sub(1, Ordering::AcqRel); } } } #[cfg(test)] -#[path = "guards_tests.rs"] +#[path = "registry_tests.rs"] mod tests; diff --git a/codex-rs/core/src/agent/registry_tests.rs b/codex-rs/core/src/agent/registry_tests.rs new file mode 100644 index 0000000000..43d91952a7 --- /dev/null +++ b/codex-rs/core/src/agent/registry_tests.rs @@ -0,0 +1,324 @@ +use super::*; +use codex_protocol::AgentPath; +use pretty_assertions::assert_eq; +use std::collections::HashSet; + +fn agent_path(path: &str) -> AgentPath { + AgentPath::try_from(path).expect("valid agent path") +} + +fn agent_metadata(thread_id: ThreadId) -> AgentMetadata { + AgentMetadata { + agent_id: Some(thread_id), + ..Default::default() + } +} + +#[test] +fn format_agent_nickname_adds_ordinals_after_reset() { + assert_eq!(format_agent_nickname("Plato", 0), "Plato"); + assert_eq!(format_agent_nickname("Plato", 1), "Plato the 2nd"); + assert_eq!(format_agent_nickname("Plato", 2), "Plato the 3rd"); + assert_eq!(format_agent_nickname("Plato", 10), "Plato the 11th"); + assert_eq!(format_agent_nickname("Plato", 20), "Plato the 21st"); +} + +#[test] +fn session_depth_defaults_to_zero_for_root_sources() { + assert_eq!(session_depth(&SessionSource::Cli), 0); +} + +#[test] +fn thread_spawn_depth_increments_and_enforces_limit() { + let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: ThreadId::new(), + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + }); + let child_depth = next_thread_spawn_depth(&session_source); + assert_eq!(child_depth, 2); + assert!(exceeds_thread_spawn_depth_limit(child_depth, 1)); +} + +#[test] +fn non_thread_spawn_subagents_default_to_depth_zero() { + let session_source = SessionSource::SubAgent(SubAgentSource::Review); + assert_eq!(session_depth(&session_source), 0); + assert_eq!(next_thread_spawn_depth(&session_source), 1); + assert!(!exceeds_thread_spawn_depth_limit(1, 1)); +} + +#[test] +fn reservation_drop_releases_slot() { + let registry = Arc::new(AgentRegistry::default()); + let reservation = registry.reserve_spawn_slot(Some(1)).expect("reserve slot"); + drop(reservation); + + let reservation = registry.reserve_spawn_slot(Some(1)).expect("slot released"); + drop(reservation); +} + +#[test] +fn commit_holds_slot_until_release() { + let registry = Arc::new(AgentRegistry::default()); + let reservation = registry.reserve_spawn_slot(Some(1)).expect("reserve slot"); + let thread_id = ThreadId::new(); + reservation.commit(agent_metadata(thread_id)); + + let err = match registry.reserve_spawn_slot(Some(1)) { + Ok(_) => panic!("limit should be enforced"), + Err(err) => err, + }; + let CodexErr::AgentLimitReached { max_threads } = err else { + panic!("expected CodexErr::AgentLimitReached"); + }; + assert_eq!(max_threads, 1); + + registry.release_spawned_thread(thread_id); + let reservation = registry + .reserve_spawn_slot(Some(1)) + .expect("slot released after thread removal"); + drop(reservation); +} + +#[test] +fn release_ignores_unknown_thread_id() { + let registry = Arc::new(AgentRegistry::default()); + let reservation = registry.reserve_spawn_slot(Some(1)).expect("reserve slot"); + let thread_id = ThreadId::new(); + reservation.commit(agent_metadata(thread_id)); + + registry.release_spawned_thread(ThreadId::new()); + + let err = match registry.reserve_spawn_slot(Some(1)) { + Ok(_) => panic!("limit should still be enforced"), + Err(err) => err, + }; + let CodexErr::AgentLimitReached { max_threads } = err else { + panic!("expected CodexErr::AgentLimitReached"); + }; + assert_eq!(max_threads, 1); + + registry.release_spawned_thread(thread_id); + let reservation = registry + .reserve_spawn_slot(Some(1)) + .expect("slot released after real thread removal"); + drop(reservation); +} + +#[test] +fn release_is_idempotent_for_registered_threads() { + let registry = Arc::new(AgentRegistry::default()); + let reservation = registry.reserve_spawn_slot(Some(1)).expect("reserve slot"); + let first_id = ThreadId::new(); + reservation.commit(agent_metadata(first_id)); + + registry.release_spawned_thread(first_id); + + let reservation = registry.reserve_spawn_slot(Some(1)).expect("slot reused"); + let second_id = ThreadId::new(); + reservation.commit(agent_metadata(second_id)); + + registry.release_spawned_thread(first_id); + + let err = match registry.reserve_spawn_slot(Some(1)) { + Ok(_) => panic!("limit should still be enforced"), + Err(err) => err, + }; + let CodexErr::AgentLimitReached { max_threads } = err else { + panic!("expected CodexErr::AgentLimitReached"); + }; + assert_eq!(max_threads, 1); + + registry.release_spawned_thread(second_id); + let reservation = registry + .reserve_spawn_slot(Some(1)) + .expect("slot released after second thread removal"); + drop(reservation); +} + +#[test] +fn failed_spawn_keeps_nickname_marked_used() { + let registry = Arc::new(AgentRegistry::default()); + let mut reservation = registry.reserve_spawn_slot(None).expect("reserve slot"); + let agent_nickname = reservation + .reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None) + .expect("reserve agent name"); + assert_eq!(agent_nickname, "alpha"); + drop(reservation); + + let mut reservation = registry.reserve_spawn_slot(None).expect("reserve slot"); + let agent_nickname = reservation + .reserve_agent_nickname_with_preference(&["alpha", "beta"], /*preferred*/ None) + .expect("unused name should still be preferred"); + assert_eq!(agent_nickname, "beta"); +} + +#[test] +fn agent_nickname_resets_used_pool_when_exhausted() { + let registry = Arc::new(AgentRegistry::default()); + let mut first = registry + .reserve_spawn_slot(None) + .expect("reserve first slot"); + let first_name = first + .reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None) + .expect("reserve first agent name"); + let first_id = ThreadId::new(); + first.commit(agent_metadata(first_id)); + assert_eq!(first_name, "alpha"); + + let mut second = registry + .reserve_spawn_slot(None) + .expect("reserve second slot"); + let second_name = second + .reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None) + .expect("name should be reused after pool reset"); + assert_eq!(second_name, "alpha the 2nd"); + let active_agents = registry + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(active_agents.nickname_reset_count, 1); +} + +#[test] +fn released_nickname_stays_used_until_pool_reset() { + let registry = Arc::new(AgentRegistry::default()); + + let mut first = registry + .reserve_spawn_slot(None) + .expect("reserve first slot"); + let first_name = first + .reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None) + .expect("reserve first agent name"); + let first_id = ThreadId::new(); + first.commit(agent_metadata(first_id)); + assert_eq!(first_name, "alpha"); + + registry.release_spawned_thread(first_id); + + let mut second = registry + .reserve_spawn_slot(None) + .expect("reserve second slot"); + let second_name = second + .reserve_agent_nickname_with_preference(&["alpha", "beta"], /*preferred*/ None) + .expect("released name should still be marked used"); + assert_eq!(second_name, "beta"); + let second_id = ThreadId::new(); + second.commit(agent_metadata(second_id)); + registry.release_spawned_thread(second_id); + + let mut third = registry + .reserve_spawn_slot(None) + .expect("reserve third slot"); + let third_name = third + .reserve_agent_nickname_with_preference(&["alpha", "beta"], /*preferred*/ None) + .expect("pool reset should permit a duplicate"); + let expected_names = HashSet::from(["alpha the 2nd".to_string(), "beta the 2nd".to_string()]); + assert!(expected_names.contains(&third_name)); + let active_agents = registry + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(active_agents.nickname_reset_count, 1); +} + +#[test] +fn repeated_resets_advance_the_ordinal_suffix() { + let registry = Arc::new(AgentRegistry::default()); + + let mut first = registry + .reserve_spawn_slot(None) + .expect("reserve first slot"); + let first_name = first + .reserve_agent_nickname_with_preference(&["Plato"], /*preferred*/ None) + .expect("reserve first agent name"); + let first_id = ThreadId::new(); + first.commit(agent_metadata(first_id)); + assert_eq!(first_name, "Plato"); + registry.release_spawned_thread(first_id); + + let mut second = registry + .reserve_spawn_slot(None) + .expect("reserve second slot"); + let second_name = second + .reserve_agent_nickname_with_preference(&["Plato"], /*preferred*/ None) + .expect("reserve second agent name"); + let second_id = ThreadId::new(); + second.commit(agent_metadata(second_id)); + assert_eq!(second_name, "Plato the 2nd"); + registry.release_spawned_thread(second_id); + + let mut third = registry + .reserve_spawn_slot(None) + .expect("reserve third slot"); + let third_name = third + .reserve_agent_nickname_with_preference(&["Plato"], /*preferred*/ None) + .expect("reserve third agent name"); + assert_eq!(third_name, "Plato the 3rd"); + let active_agents = registry + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(active_agents.nickname_reset_count, 2); +} + +#[test] +fn register_root_thread_indexes_root_path() { + let registry = Arc::new(AgentRegistry::default()); + let root_thread_id = ThreadId::new(); + + registry.register_root_thread(root_thread_id); + + assert_eq!( + registry.agent_id_for_path(&AgentPath::root()), + Some(root_thread_id) + ); +} + +#[test] +fn reserved_agent_path_is_released_when_spawn_fails() { + let registry = Arc::new(AgentRegistry::default()); + let mut first = registry + .reserve_spawn_slot(None) + .expect("reserve first slot"); + first + .reserve_agent_path(&agent_path("/root/researcher")) + .expect("reserve first path"); + drop(first); + + let mut second = registry + .reserve_spawn_slot(None) + .expect("reserve second slot"); + second + .reserve_agent_path(&agent_path("/root/researcher")) + .expect("dropped reservation should free the path"); +} + +#[test] +fn committed_agent_path_is_indexed_until_release() { + let registry = Arc::new(AgentRegistry::default()); + let thread_id = ThreadId::new(); + let mut reservation = registry.reserve_spawn_slot(None).expect("reserve slot"); + reservation + .reserve_agent_path(&agent_path("/root/researcher")) + .expect("reserve path"); + reservation.commit(AgentMetadata { + agent_id: Some(thread_id), + agent_path: Some(agent_path("/root/researcher")), + ..Default::default() + }); + + assert_eq!( + registry.agent_id_for_path(&agent_path("/root/researcher")), + Some(thread_id) + ); + + registry.release_spawned_thread(thread_id); + assert_eq!( + registry.agent_id_for_path(&agent_path("/root/researcher")), + None + ); +} diff --git a/codex-rs/core/src/arc_monitor.rs b/codex-rs/core/src/arc_monitor.rs index c704faafc0..ba9a886de6 100644 --- a/codex-rs/core/src/arc_monitor.rs +++ b/codex-rs/core/src/arc_monitor.rs @@ -99,6 +99,7 @@ pub(crate) async fn monitor_action( sess: &Session, turn_context: &TurnContext, action: serde_json::Value, + protection_client_callsite: &'static str, ) -> ArcMonitorOutcome { let auth = match turn_context.auth_manager.as_ref() { Some(auth_manager) => match auth_manager.auth().await { @@ -138,7 +139,8 @@ pub(crate) async fn monitor_action( return ArcMonitorOutcome::Ok; } }; - let body = build_arc_monitor_request(sess, turn_context, action).await; + let body = + build_arc_monitor_request(sess, turn_context, action, protection_client_callsite).await; let client = build_reqwest_client(); let mut request = client .post(&url) @@ -236,6 +238,7 @@ async fn build_arc_monitor_request( sess: &Session, turn_context: &TurnContext, action: serde_json::Map, + protection_client_callsite: &'static str, ) -> ArcMonitorRequest { let history = sess.clone_history().await; let mut messages = build_arc_monitor_messages(history.raw_items()); @@ -254,7 +257,7 @@ async fn build_arc_monitor_request( codex_thread_id: conversation_id.clone(), codex_turn_id: turn_context.sub_id.clone(), conversation_id: Some(conversation_id), - protection_client_callsite: None, + protection_client_callsite: Some(protection_client_callsite.to_string()), }, messages: Some(messages), input: None, diff --git a/codex-rs/core/src/arc_monitor_tests.rs b/codex-rs/core/src/arc_monitor_tests.rs index cd0cb80b14..2c21505636 100644 --- a/codex-rs/core/src/arc_monitor_tests.rs +++ b/codex-rs/core/src/arc_monitor_tests.rs @@ -182,6 +182,7 @@ async fn build_arc_monitor_request_includes_relevant_history_and_null_policies() &turn_context, serde_json::from_value(serde_json::json!({ "tool": "mcp_tool_call" })) .expect("action should deserialize"), + "normal", ) .await; @@ -192,7 +193,7 @@ async fn build_arc_monitor_request_includes_relevant_history_and_null_policies() codex_thread_id: session.conversation_id.to_string(), codex_turn_id: turn_context.sub_id.clone(), conversation_id: Some(session.conversation_id.to_string()), - protection_client_callsite: None, + protection_client_callsite: Some("normal".to_string()), }, messages: Some(vec![ ArcMonitorChatMessage { @@ -290,6 +291,7 @@ async fn monitor_action_posts_expected_arc_request() { "codex_thread_id": session.conversation_id.to_string(), "codex_turn_id": turn_context.sub_id.clone(), "conversation_id": session.conversation_id.to_string(), + "protection_client_callsite": "normal", }, "messages": [{ "role": "user", @@ -325,6 +327,7 @@ async fn monitor_action_posts_expected_arc_request() { &session, &turn_context, serde_json::json!({ "tool": "mcp_tool_call" }), + "normal", ) .await; @@ -383,6 +386,7 @@ async fn monitor_action_uses_env_url_and_token_overrides() { &session, &turn_context, serde_json::json!({ "tool": "mcp_tool_call" }), + "normal", ) .await; @@ -435,6 +439,7 @@ async fn monitor_action_rejects_legacy_response_fields() { &session, &turn_context, serde_json::json!({ "tool": "mcp_tool_call" }), + "normal", ) .await; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 254d5708bb..345b4d04e3 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -189,9 +189,41 @@ mod rollout_reconstruction_tests; pub enum SteerInputError { NoActiveTurn(Vec), ExpectedTurnMismatch { expected: String, actual: String }, + ActiveTurnNotSteerable { turn_kind: NonSteerableTurnKind }, EmptyInput, } +impl SteerInputError { + fn to_error_event(&self) -> ErrorEvent { + match self { + Self::NoActiveTurn(_) => ErrorEvent { + message: "no active turn to steer".to_string(), + codex_error_info: Some(CodexErrorInfo::BadRequest), + }, + Self::ExpectedTurnMismatch { expected, actual } => ErrorEvent { + message: format!("expected active turn id `{expected}` but found `{actual}`"), + codex_error_info: Some(CodexErrorInfo::BadRequest), + }, + Self::ActiveTurnNotSteerable { turn_kind } => { + let turn_kind_label = match turn_kind { + NonSteerableTurnKind::Review => "review", + NonSteerableTurnKind::Compact => "compact", + }; + ErrorEvent { + message: format!("cannot steer a {turn_kind_label} turn"), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: *turn_kind, + }), + } + } + Self::EmptyInput => ErrorEvent { + message: "input must not be empty".to_string(), + codex_error_info: Some(CodexErrorInfo::BadRequest), + }, + } + } +} + /// Notes from the previous real user turn. /// /// Conceptually this is the same role that `previous_model` used to fill, but @@ -336,6 +368,7 @@ use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::InitialHistory; +use codex_protocol::protocol::NonSteerableTurnKind; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_readiness::Readiness; @@ -768,6 +801,7 @@ pub(crate) struct Session { pending_mcp_server_refresh_config: Mutex>, pub(crate) conversation: Arc, pub(crate) active_turn: Mutex>, + idle_pending_input: Mutex>, pub(crate) guardian_review_session: GuardianReviewSessionManager, pub(crate) services: SessionServices, js_repl: Arc, @@ -779,6 +813,7 @@ pub(crate) struct TurnSkillsContext { pub(crate) outcome: Arc, pub(crate) implicit_invocation_seen_skills: Arc>>, } + impl TurnSkillsContext { pub(crate) fn new(outcome: Arc) -> Self { Self { @@ -1865,6 +1900,7 @@ impl Session { pending_mcp_server_refresh_config: Mutex::new(None), conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), + idle_pending_input: Mutex::new(Vec::new()), guardian_review_session: GuardianReviewSessionManager::default(), services, js_repl, @@ -3488,6 +3524,7 @@ impl Session { DeveloperInstructions::from_policy( turn_context.sandbox_policy.get(), turn_context.approval_policy.value(), + turn_context.config.approvals_reviewer, self.services.exec_policy.current().as_ref(), &turn_context.cwd, turn_context @@ -3907,6 +3944,21 @@ impl Session { }); } + match active_turn.tasks.first().map(|(_, task)| task.kind) { + Some(crate::state::TaskKind::Regular) => {} + Some(crate::state::TaskKind::Review) => { + return Err(SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }); + } + Some(crate::state::TaskKind::Compact) => { + return Err(SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }); + } + None => return Err(SteerInputError::NoActiveTurn(input)), + } + let mut input_item: ResponseInputItem = input.into(); if self.enabled(Feature::ItemMetadata) { stamp_user_message_type_on_input_item(&mut input_item, UserMessageType::PromptSteering); @@ -3917,7 +3969,7 @@ impl Session { Ok(active_turn_id.clone()) } - /// Returns the input if there was no task running to inject into + /// Returns the input if there was no task running to inject into. pub async fn inject_response_items( &self, input: Vec, @@ -3966,6 +4018,24 @@ impl Session { } } + /// 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) { + if items.is_empty() { + return; + } + + let mut idle_pending_input = self.idle_pending_input.lock().await; + idle_pending_input.extend(items); + } + + pub(crate) async fn take_queued_response_items_for_next_turn(&self) -> Vec { + std::mem::take(&mut *self.idle_pending_input.lock().await) + } + + pub(crate) async fn has_queued_response_items_for_next_turn(&self) -> bool { + !self.idle_pending_input.lock().await.is_empty() + } + pub async fn has_pending_input(&self) -> bool { let active = self.active_turn.lock().await; match active.as_ref() { @@ -4587,26 +4657,35 @@ mod handlers { _ => unreachable!(), }; - let Ok(current_context) = sess.new_turn_with_sub_id(sub_id, updates).await else { + let Ok(current_context) = sess.new_turn_with_sub_id(sub_id.clone(), updates).await else { // new_turn_with_sub_id already emits the error event. return; }; sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; - current_context.session_telemetry.user_prompt(&items); - - // Attempt to inject input into current task. - if let Err(SteerInputError::NoActiveTurn(items)) = - sess.steer_input(items, /*expected_turn_id*/ None).await + match sess + .steer_input(items.clone(), /*expected_turn_id*/ None) + .await { - sess.refresh_mcp_servers_if_requested(¤t_context) + Ok(_) => current_context.session_telemetry.user_prompt(&items), + Err(SteerInputError::NoActiveTurn(items)) => { + current_context.session_telemetry.user_prompt(&items); + sess.refresh_mcp_servers_if_requested(¤t_context) + .await; + sess.spawn_task( + Arc::clone(¤t_context), + items, + crate::tasks::RegularTask::new(), + ) .await; - sess.spawn_task( - Arc::clone(¤t_context), - items, - crate::tasks::RegularTask::new(), - ) - .await; + } + Err(err) => { + sess.send_event_raw(Event { + id: sub_id, + msg: EventMsg::Error(err.to_error_event()), + }) + .await; + } } } @@ -5445,7 +5524,7 @@ pub(crate) async fn run_turn( prewarmed_client_session: Option, cancellation_token: CancellationToken, ) -> Option { - if input.is_empty() { + if input.is_empty() && !sess.has_pending_input().await { return None; } @@ -5572,20 +5651,6 @@ pub(crate) async fn run_turn( &available_connectors, &skill_name_counts_lower, )); - // Explicit plugin mentions can make a plugin's enabled apps callable for - // this turn without persisting those connectors as sticky user selections. - let mut turn_enabled_connectors = explicitly_enabled_connectors.clone(); - turn_enabled_connectors.extend( - mentioned_plugins - .iter() - .flat_map(|plugin| plugin.app_connector_ids.iter()) - .map(|connector_id| connector_id.0.clone()) - .filter(|connector_id| { - available_connectors - .iter() - .any(|connector| connector.is_enabled && connector.id == *connector_id) - }), - ); let connector_names_by_id = available_connectors .iter() .map(|connector| (connector.id.as_str(), connector.name.as_str())) @@ -5601,23 +5666,36 @@ pub(crate) async fn run_turn( }) .collect::>(); - let mut last_agent_message: Option = None; if run_pending_session_start_hooks(&sess, &turn_context).await { - return last_agent_message; + return None; } - let user_prompt_submit_outcome = - run_user_prompt_submit_hooks(&sess, &turn_context, UserMessageItem::new(&input).message()) - .await; - if user_prompt_submit_outcome.should_stop { - record_additional_contexts( + let additional_contexts = if input.is_empty() { + Vec::new() + } else { + let user_prompt_submit_outcome = run_user_prompt_submit_hooks( &sess, &turn_context, - user_prompt_submit_outcome.additional_contexts, + UserMessageItem::new(&input).message(), ) .await; - return last_agent_message; - } - let additional_contexts = user_prompt_submit_outcome.additional_contexts; + if user_prompt_submit_outcome.should_stop { + record_additional_contexts( + &sess, + &turn_context, + user_prompt_submit_outcome.additional_contexts, + ) + .await; + return None; + } + let mut initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input.clone()); + if sess.enabled(Feature::ItemMetadata) { + stamp_user_message_type_on_input_item(&mut initial_input_for_turn, UserMessageType::Prompt); + } + let response_item: ResponseItem = initial_input_for_turn.clone().into(); + sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), &input, response_item) + .await; + user_prompt_submit_outcome.additional_contexts + }; sess.services .analytics_events_client .track_app_mentioned(tracking.clone(), mentioned_app_invocations); @@ -5628,23 +5706,17 @@ pub(crate) async fn run_turn( } sess.merge_connector_selection(explicitly_enabled_connectors.clone()) .await; - - let mut initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input.clone()); - if sess.enabled(Feature::ItemMetadata) { - stamp_user_message_type_on_input_item(&mut initial_input_for_turn, UserMessageType::Prompt); - } - let response_item: ResponseItem = initial_input_for_turn.clone().into(); - sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), &input, response_item) - .await; record_additional_contexts(&sess, &turn_context, additional_contexts).await; - // Track the previous-turn baseline from the regular user-turn path only so - // standalone tasks (compact/shell/review/undo) cannot suppress future - // model/realtime injections. - sess.set_previous_turn_settings(Some(PreviousTurnSettings { - model: turn_context.model_info.slug.clone(), - realtime_active: Some(turn_context.realtime_active), - })) - .await; + if !input.is_empty() { + // Track the previous-turn baseline from the regular user-turn path only so + // standalone tasks (compact/shell/review/undo) cannot suppress future + // model/realtime injections. + sess.set_previous_turn_settings(Some(PreviousTurnSettings { + model: turn_context.model_info.slug.clone(), + realtime_active: Some(turn_context.realtime_active), + })) + .await; + } if !skill_items.is_empty() { sess.record_conversation_items(&turn_context, &skill_items) @@ -5655,8 +5727,10 @@ pub(crate) async fn run_turn( .await; } + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; + let mut last_agent_message: Option = None; let mut stop_hook_active = false; // Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains // many turns, from the perspective of the user, it is a single turn. @@ -5741,7 +5815,7 @@ pub(crate) async fn run_turn( &mut client_session, turn_metadata_header.as_deref(), sampling_request_input, - &turn_enabled_connectors, + &explicitly_enabled_connectors, skills_outcome, &mut server_model_warning_emitted_for_turn, cancellation_token.child_token(), diff --git a/codex-rs/core/src/codex/rollout_reconstruction.rs b/codex-rs/core/src/codex/rollout_reconstruction.rs index 11ddbc1928..a4c042af0c 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction.rs @@ -1,4 +1,5 @@ use super::*; +use crate::context_manager::is_user_turn_boundary; // Return value of `Session::reconstruct_history_from_rollout`, bundling the rebuilt history with // the resume/fork hydration metadata derived from the same replay. @@ -201,9 +202,12 @@ impl Session { ); } } - RolloutItem::ResponseItem(_) - | RolloutItem::EventMsg(_) - | RolloutItem::SessionMeta(_) => {} + RolloutItem::ResponseItem(response_item) => { + let active_segment = + active_segment.get_or_insert_with(ActiveReplaySegment::default); + active_segment.counts_as_user_turn |= is_user_turn_boundary(response_item); + } + RolloutItem::EventMsg(_) | RolloutItem::SessionMeta(_) => {} } if base_replacement_history.is_some() diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 2736905390..01f078700d 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -35,6 +35,18 @@ fn assistant_message(text: &str) -> ResponseItem { } } +fn inter_agent_assistant_message(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: text.to_string(), + }], + end_turn: None, + phase: None, + } +} + #[tokio::test] async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previous_turn_settings() { @@ -432,6 +444,96 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad ); } +#[tokio::test] +async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { + let (session, turn_context) = make_session_and_context().await; + let first_context_item = turn_context.to_turn_context_item(); + let first_turn_id = first_context_item + .turn_id + .clone() + .expect("turn context should have turn_id"); + let assistant_turn_id = "assistant-instruction-turn".to_string(); + let assistant_turn_context = TurnContextItem { + turn_id: Some(assistant_turn_id.clone()), + ..first_context_item.clone() + }; + let assistant_instruction = inter_agent_assistant_message( + "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue", + ); + let assistant_reply = assistant_message("worker reply"); + + let rollout_items = vec![ + RolloutItem::EventMsg(EventMsg::TurnStarted( + codex_protocol::protocol::TurnStartedEvent { + turn_id: first_turn_id.clone(), + model_context_window: Some(128_000), + collaboration_mode_kind: ModeKind::Default, + }, + )), + RolloutItem::EventMsg(EventMsg::UserMessage( + codex_protocol::protocol::UserMessageEvent { + message: "turn 1 user".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + }, + )), + RolloutItem::TurnContext(first_context_item.clone()), + RolloutItem::ResponseItem(user_message("turn 1 user")), + RolloutItem::ResponseItem(assistant_message("turn 1 assistant")), + RolloutItem::EventMsg(EventMsg::TurnComplete( + codex_protocol::protocol::TurnCompleteEvent { + turn_id: first_turn_id, + last_agent_message: None, + }, + )), + RolloutItem::EventMsg(EventMsg::TurnStarted( + codex_protocol::protocol::TurnStartedEvent { + turn_id: assistant_turn_id.clone(), + model_context_window: Some(128_000), + collaboration_mode_kind: ModeKind::Default, + }, + )), + RolloutItem::TurnContext(assistant_turn_context), + RolloutItem::ResponseItem(assistant_instruction), + RolloutItem::ResponseItem(assistant_reply), + RolloutItem::EventMsg(EventMsg::TurnComplete( + codex_protocol::protocol::TurnCompleteEvent { + turn_id: assistant_turn_id, + last_agent_message: None, + }, + )), + RolloutItem::EventMsg(EventMsg::ThreadRolledBack( + codex_protocol::protocol::ThreadRolledBackEvent { num_turns: 1 }, + )), + ]; + + let reconstructed = session + .reconstruct_history_from_rollout(&turn_context, &rollout_items) + .await; + + assert_eq!( + reconstructed.history, + vec![ + user_message("turn 1 user"), + assistant_message("turn 1 assistant") + ] + ); + assert_eq!( + reconstructed.previous_turn_settings, + Some(PreviousTurnSettings { + model: turn_context.model_info.slug.clone(), + realtime_active: Some(turn_context.realtime_active), + }) + ); + assert_eq!( + serde_json::to_value(reconstructed.reference_context_item) + .expect("serialize reconstructed reference context item"), + serde_json::to_value(Some(first_context_item)) + .expect("serialize expected reference context item") + ); +} + #[tokio::test] async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding_user_turns() { let (session, turn_context) = make_session_and_context().await; diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 5fd782fa45..d9687b1d3a 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -25,6 +25,7 @@ use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::protocol::NonSteerableTurnKind; use codex_protocol::protocol::ReadOnlyAccess; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::request_permissions::PermissionGrantScope; @@ -65,17 +66,31 @@ use codex_execpolicy::NetworkRuleProtocol; use codex_execpolicy::Policy; use codex_network_proxy::NetworkProxyConfig; use codex_otel::TelemetryAuthMode; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; use codex_protocol::models::DeveloperInstructions; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelsResponse; +use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ConversationAudioParams; use codex_protocol::protocol::RealtimeAudioFrame; use codex_protocol::protocol::Submission; use codex_protocol::protocol::W3cTraceContext; +use core_test_support::context_snapshot; +use core_test_support::context_snapshot::ContextSnapshotOptions; +use core_test_support::context_snapshot::ContextSnapshotRenderMode; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::test_codex::test_codex; use core_test_support::tracing::install_test_tracing; +use core_test_support::wait_for_event; use opentelemetry::trace::TraceContextExt; use opentelemetry::trace::TraceId; use std::path::Path; @@ -645,6 +660,21 @@ fn filter_connectors_for_input_skips_disabled_connectors() { assert_eq!(selected, Vec::new()); } +#[test] +fn filter_connectors_for_input_skips_plugin_mentions() { + let connectors = vec![make_connector("figma", "Figma")]; + let input = vec![user_message("use [@figma](plugin://figma@openai-curated)")]; + let explicitly_enabled_connectors = HashSet::new(); + let selected = filter_connectors_for_input( + &connectors, + &input, + &explicitly_enabled_connectors, + &HashMap::new(), + ); + + assert_eq!(selected, Vec::new()); +} + #[test] fn collect_explicit_app_ids_from_skill_items_includes_linked_mentions() { let connectors = vec![make_connector("calendar", "Calendar")]; @@ -1106,6 +1136,111 @@ async fn record_initial_history_reconstructs_forked_transcript() { assert_eq!(expected, history.raw_items()); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<()> { + let server = start_mock_server().await; + mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + let first_forked_request = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + ) + .await; + + let mut builder = test_codex().with_config(|config| { + config.permissions.approval_policy = + codex_config::Constrained::allow_any(AskForApproval::OnRequest); + }); + let initial = builder.build(&server).await?; + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("rollout path"); + + initial + .codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "fork seed".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let mut fork_config = initial.config.clone(); + fork_config.permissions.approval_policy = + codex_config::Constrained::allow_any(AskForApproval::UnlessTrusted); + let forked = initial + .thread_manager + .fork_thread(usize::MAX, fork_config, rollout_path, false, None) + .await?; + + let collaboration_mode = CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: forked.session_configured.model.clone(), + reasoning_effort: None, + developer_instructions: Some("Fork turn collaboration instructions.".to_string()), + }, + }; + forked + .thread + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: Some(AskForApproval::Never), + approvals_reviewer: None, + sandbox_policy: None, + windows_sandbox_level: None, + model: None, + effort: None, + summary: None, + service_tier: None, + collaboration_mode: Some(collaboration_mode), + personality: None, + }) + .await?; + + forked + .thread + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "after fork".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&forked.thread, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let request = first_forked_request.single_request(); + let snapshot = context_snapshot::format_labeled_requests_snapshot( + "First request after fork when fork startup changes approval policy and the first forked turn changes approval policy again and enters plan mode.", + &[("First Forked Turn Request", &request)], + &ContextSnapshotOptions::default() + .render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 96 }) + .strip_capability_instructions() + .strip_agents_md_user_context(), + ); + + let mut settings = insta::Settings::clone_current(); + settings.set_snapshot_path("snapshots"); + settings.set_prepend_module_to_snapshot(false); + settings.bind(|| { + insta::assert_snapshot!( + "codex_core__codex_tests__fork_startup_context_then_first_turn_diff", + snapshot + ); + }); + + Ok(()) +} + #[tokio::test] async fn record_initial_history_forked_hydrates_previous_turn_settings() { let (session, turn_context) = make_session_and_context().await; @@ -2553,6 +2688,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { pending_mcp_server_refresh_config: Mutex::new(None), conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), + idle_pending_input: Mutex::new(Vec::new()), guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(), services, js_repl, @@ -3352,6 +3488,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( pending_mcp_server_refresh_config: Mutex::new(None), conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), + idle_pending_input: Mutex::new(Vec::new()), guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(), services, js_repl, @@ -4467,6 +4604,43 @@ async fn steer_input_enforces_expected_turn_id() { } } +#[tokio::test] +async fn steer_input_rejects_non_regular_turns() { + for (task_kind, turn_kind) in [ + (TaskKind::Review, NonSteerableTurnKind::Review), + (TaskKind::Compact, NonSteerableTurnKind::Compact), + ] { + let (sess, _tc, _rx) = make_session_and_context_with_rx().await; + let input = vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }]; + let turn_context = sess.new_default_turn_with_sub_id("turn".to_string()).await; + sess.spawn_task( + turn_context, + input, + NeverEndingTask { + kind: task_kind, + listen_to_cancellation_token: true, + }, + ) + .await; + + let steer_input = vec![UserInput::Text { + text: "steer".to_string(), + text_elements: Vec::new(), + }]; + let err = sess + .steer_input(steer_input, /*expected_turn_id*/ None) + .await + .expect_err("steering a non-regular turn should fail"); + + assert_eq!(err, SteerInputError::ActiveTurnNotSteerable { turn_kind }); + + sess.abort_all_tasks(TurnAbortReason::Interrupted).await; + } +} + #[tokio::test] async fn steer_input_returns_active_turn_id() { let (sess, tc, _rx) = make_session_and_context_with_rx().await; @@ -4637,6 +4811,32 @@ async fn prepend_pending_input_keeps_older_tail_ahead_of_newer_input() { assert_eq!(sess.get_pending_input().await, vec![later, newer]); } +#[tokio::test] +async fn queued_response_items_for_next_turn_move_into_next_active_turn() { + let (sess, tc, _rx) = make_session_and_context_with_rx().await; + let queued_item = ResponseInputItem::Message { + role: "assistant".to_string(), + content: vec![ContentItem::InputText { + text: "queued before wake".to_string(), + }], + }; + + sess.queue_response_items_for_next_turn(vec![queued_item.clone()]) + .await; + + sess.spawn_task( + Arc::clone(&tc), + Vec::new(), + NeverEndingTask { + kind: TaskKind::Regular, + listen_to_cancellation_token: false, + }, + ) + .await; + + assert_eq!(sess.get_pending_input().await, vec![queued_item]); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn abort_review_task_emits_exited_then_aborted_and_records_history() { let (sess, tc, rx) = make_session_and_context_with_rx().await; diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index fd47046a9f..04a4361ac1 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -1,4 +1,6 @@ use crate::agent::AgentStatus; +use crate::agent::inter_agent_instruction::InterAgentDelivery; +use crate::agent::inter_agent_instruction::InterAgentInstruction; use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; @@ -122,30 +124,91 @@ impl CodexThread { /// Records a user-role session-prefix message without creating a new user turn boundary. pub(crate) async fn inject_user_message_without_turn(&self, message: String) { - let pending_item = ResponseInputItem::Message { + let message = ResponseItem::Message { + id: None, role: "user".to_string(), content: vec![ContentItem::InputText { text: message }], metadata: None, + end_turn: None, + phase: None, }; - let pending_items = vec![pending_item]; - let Err(items_without_active_turn) = self + let pending_item = match pending_message_input_item(&message) { + Ok(pending_item) => pending_item, + Err(err) => { + debug_assert!(false, "session-prefix message append should succeed: {err}"); + return; + } + }; + if self .codex .session - .inject_response_items(pending_items) + .inject_response_items(vec![pending_item]) .await - else { - return; - }; + .is_err() + { + let turn_context = self.codex.session.new_default_turn().await; + self.codex + .session + .record_conversation_items(turn_context.as_ref(), &[message]) + .await; + } + } - let turn_context = self.codex.session.new_default_turn().await; - let items: Vec = items_without_active_turn - .into_iter() - .map(ResponseItem::from) - .collect(); + /// Append a prebuilt message to the thread history without treating it as a user turn. + /// + /// If the thread already has an active turn, the message is queued as pending input for that + /// turn. Otherwise it is queued at session scope and a regular turn is started so the agent + /// can consume that pending input through the normal turn pipeline. + pub(crate) async fn append_message(&self, message: ResponseItem) -> CodexResult { + let submission_id = uuid::Uuid::new_v4().to_string(); + let pending_item = pending_message_input_item(&message)?; + if let Err(items) = self + .codex + .session + .inject_response_items(vec![pending_item]) + .await + { + self.codex + .session + .queue_response_items_for_next_turn(items) + .await; + self.codex + .session + .ensure_task_for_queued_response_items() + .await; + } + + Ok(submission_id) + } + + pub(crate) async fn deliver_inter_agent_instruction( + &self, + instruction: InterAgentInstruction, + delivery: InterAgentDelivery, + ) -> CodexResult { + let message = instruction.to_response_item(); + match delivery { + InterAgentDelivery::CurrentTurn => self.append_message(message).await, + InterAgentDelivery::NextTurn => self.queue_message_for_next_turn(message).await, + } + } + + /// Queue a prebuilt message so the next turn records it before any submitted user input. + pub(crate) async fn queue_message_for_next_turn( + &self, + message: ResponseItem, + ) -> CodexResult { + let submission_id = uuid::Uuid::new_v4().to_string(); + let pending_item = pending_message_input_item(&message)?; self.codex .session - .record_conversation_items(turn_context.as_ref(), &items) + .queue_response_items_for_next_turn(vec![pending_item]) .await; + self.codex + .session + .ensure_task_for_queued_response_items() + .await; + Ok(submission_id) } pub fn rollout_path(&self) -> Option { @@ -199,3 +262,15 @@ impl CodexThread { Ok(*guard) } } + +fn pending_message_input_item(message: &ResponseItem) -> CodexResult { + match message { + ResponseItem::Message { role, content, .. } => Ok(ResponseInputItem::Message { + role: role.clone(), + content: content.clone(), + }), + _ => Err(CodexErr::InvalidRequest( + "append_message only supports ResponseItem::Message".to_string(), + )), + } +} diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 1c78759ec4..7786b30873 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -5582,16 +5582,69 @@ shell_tool = true Ok(()) } +#[cfg(target_os = "linux")] #[test] -fn missing_system_bwrap_warning_matches_system_bwrap_presence() { - #[cfg(target_os = "linux")] - assert_eq!( - missing_system_bwrap_warning().is_some(), - !Path::new("/usr/bin/bwrap").is_file() - ); +fn system_bwrap_warning_reports_missing_system_bwrap() { + let warning = system_bwrap_warning_for_path(Path::new("/definitely/not/a/bwrap")) + .expect("missing system bwrap should emit a warning"); - #[cfg(not(target_os = "linux"))] - assert!(missing_system_bwrap_warning().is_none()); + assert!(warning.contains("could not find system bubblewrap")); +} + +#[cfg(target_os = "linux")] +#[test] +fn system_bwrap_warning_reports_too_old_system_bwrap() { + let fake_bwrap = write_fake_bwrap( + r#"#!/bin/sh +if [ "$1" = "--help" ]; then + echo 'usage: bwrap [OPTION...] COMMAND' + exit 0 +fi +exit 1 +"#, + ); + let fake_bwrap_path: &Path = fake_bwrap.as_ref(); + let warning = system_bwrap_warning_for_path(fake_bwrap_path) + .expect("old system bwrap should emit a warning"); + + assert!(warning.contains("too old to support `--argv0`")); +} + +#[cfg(target_os = "linux")] +#[test] +fn system_bwrap_warning_skips_supported_system_bwrap() { + let fake_bwrap = write_fake_bwrap( + r#"#!/bin/sh +if [ "$1" = "--help" ]; then + echo ' --argv0 PROGRAM' + exit 0 +fi +exit 1 +"#, + ); + let fake_bwrap_path: &Path = fake_bwrap.as_ref(); + + assert_eq!(system_bwrap_warning_for_path(fake_bwrap_path), None); +} + +#[cfg(not(target_os = "linux"))] +#[test] +fn system_bwrap_warning_is_disabled_off_linux() { + assert!(system_bwrap_warning().is_none()); +} + +#[cfg(target_os = "linux")] +fn write_fake_bwrap(contents: &str) -> tempfile::TempPath { + use std::fs; + use std::os::unix::fs::PermissionsExt; + use tempfile::NamedTempFile; + + // Linux rejects exec-ing a file that is still open for writing. + let path = NamedTempFile::new().expect("temp file").into_temp_path(); + fs::write(&path, contents).expect("write fake bwrap"); + let permissions = fs::Permissions::from_mode(0o755); + fs::set_permissions(&path, permissions).expect("chmod fake bwrap"); + path } #[tokio::test] @@ -5677,7 +5730,7 @@ approvals_reviewer = "guardian_subagent" } #[tokio::test] -async fn smart_approvals_alias_is_migrated_to_guardian_approval() -> std::io::Result<()> { +async fn smart_approvals_alias_is_ignored() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), @@ -5692,23 +5745,19 @@ smart_approvals = true .build() .await?; - assert!(config.features.enabled(Feature::GuardianApproval)); - assert_eq!(config.features.legacy_feature_usages().count(), 0); - assert_eq!( - config.approvals_reviewer, - ApprovalsReviewer::GuardianSubagent - ); + assert!(!config.features.enabled(Feature::GuardianApproval)); + assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User); let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; - assert!(serialized.contains("guardian_approval = true")); - assert!(serialized.contains("approvals_reviewer = \"guardian_subagent\"")); - assert!(!serialized.contains("smart_approvals")); + assert!(serialized.contains("smart_approvals = true")); + assert!(!serialized.contains("guardian_approval")); + assert!(!serialized.contains("approvals_reviewer")); Ok(()) } #[tokio::test] -async fn smart_approvals_alias_is_migrated_in_profiles() -> std::io::Result<()> { +async fn smart_approvals_alias_is_ignored_in_profiles() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), @@ -5719,106 +5768,6 @@ smart_approvals = true "#, )?; - let config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .fallback_cwd(Some(codex_home.path().to_path_buf())) - .build() - .await?; - - assert!(config.features.enabled(Feature::GuardianApproval)); - assert_eq!(config.features.legacy_feature_usages().count(), 0); - assert_eq!( - config.approvals_reviewer, - ApprovalsReviewer::GuardianSubagent - ); - - let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; - assert!(serialized.contains("[profiles.guardian.features]")); - assert!(serialized.contains("guardian_approval = true")); - assert!(serialized.contains("approvals_reviewer = \"guardian_subagent\"")); - assert!(!serialized.contains("smart_approvals")); - - Ok(()) -} - -#[tokio::test] -async fn smart_approvals_alias_migration_preserves_disabled_profile_override() -> std::io::Result<()> -{ - let codex_home = TempDir::new()?; - std::fs::write( - codex_home.path().join(CONFIG_TOML_FILE), - r#"[features] -guardian_approval = true - -[profiles.guardian.features] -smart_approvals = false -"#, - )?; - - let config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .fallback_cwd(Some(codex_home.path().to_path_buf())) - .harness_overrides(ConfigOverrides { - config_profile: Some("guardian".to_string()), - ..Default::default() - }) - .build() - .await?; - - assert!(!config.features.enabled(Feature::GuardianApproval)); - assert_eq!(config.features.legacy_feature_usages().count(), 0); - assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User); - - let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; - assert!(serialized.contains("[profiles.guardian.features]")); - assert!(serialized.contains("guardian_approval = false")); - assert!(!serialized.contains("smart_approvals")); - - Ok(()) -} - -#[tokio::test] -async fn smart_approvals_alias_migration_preserves_existing_approvals_reviewer() --> std::io::Result<()> { - let codex_home = TempDir::new()?; - std::fs::write( - codex_home.path().join(CONFIG_TOML_FILE), - r#"approvals_reviewer = "user" - -[features] -smart_approvals = true -"#, - )?; - - let config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .fallback_cwd(Some(codex_home.path().to_path_buf())) - .build() - .await?; - - assert!(config.features.enabled(Feature::GuardianApproval)); - assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User); - - let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; - assert!(serialized.contains("guardian_approval = true")); - assert!(serialized.contains("approvals_reviewer = \"user\"")); - assert!(!serialized.contains("smart_approvals")); - - Ok(()) -} - -#[tokio::test] -async fn smart_approvals_alias_migration_does_not_override_canonical_disabled_flag() --> std::io::Result<()> { - let codex_home = TempDir::new()?; - std::fs::write( - codex_home.path().join(CONFIG_TOML_FILE), - r#"[features] -guardian_approval = false -smart_approvals = true -"#, - )?; - let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) @@ -5829,9 +5778,10 @@ smart_approvals = true assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User); let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; - assert!(serialized.contains("guardian_approval = false")); - assert!(!serialized.contains("approvals_reviewer = \"guardian_subagent\"")); - assert!(!serialized.contains("smart_approvals")); + assert!(serialized.contains("[profiles.guardian.features]")); + assert!(serialized.contains("smart_approvals = true")); + assert!(!serialized.contains("guardian_approval")); + assert!(!serialized.contains("approvals_reviewer")); Ok(()) } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 48bde3f177..cbbd023905 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -96,14 +96,16 @@ use std::collections::HashMap; use std::io::ErrorKind; use std::path::Path; use std::path::PathBuf; +#[cfg(target_os = "linux")] +use std::process::Command; use crate::config::permissions::compile_permission_profile; +use crate::config::permissions::get_readable_roots_required_for_codex_runtime; use crate::config::permissions::network_proxy_config_from_profile_network; use crate::config::profile::ConfigProfile; use codex_network_proxy::NetworkProxyConfig; use toml::Value as TomlValue; use toml_edit::DocumentMut; -use toml_edit::value; pub(crate) mod agent_roles; pub mod edit; @@ -153,21 +155,46 @@ const RESERVED_MODEL_PROVIDER_IDS: [&str; 3] = [ ]; #[cfg(target_os = "linux")] -pub fn missing_system_bwrap_warning() -> Option { - if Path::new(SYSTEM_BWRAP_PATH).is_file() { - None - } else { - Some(format!( - "Codex could not find system bubblewrap at {SYSTEM_BWRAP_PATH}. Please install bubblewrap with your package manager. Codex will use the vendored bubblewrap in the meantime." - )) - } +pub fn system_bwrap_warning() -> Option { + system_bwrap_warning_for_path(Path::new(SYSTEM_BWRAP_PATH)) } #[cfg(not(target_os = "linux"))] -pub fn missing_system_bwrap_warning() -> Option { +pub fn system_bwrap_warning() -> Option { None } +#[cfg(target_os = "linux")] +fn system_bwrap_warning_for_path(system_bwrap_path: &Path) -> Option { + if !system_bwrap_path.is_file() { + return Some(format!( + "Codex could not find system bubblewrap at {}. Please install bubblewrap with your package manager. Codex will use the vendored bubblewrap in the meantime.", + system_bwrap_path.display() + )); + } + if system_bwrap_supports_argv0(system_bwrap_path) { + return None; + } + + Some(format!( + "Codex found system bubblewrap at {}, but it is too old to support `--argv0`. Please upgrade bubblewrap with your package manager. Codex will use the vendored bubblewrap in the meantime.", + system_bwrap_path.display() + )) +} + +#[cfg(target_os = "linux")] +fn system_bwrap_supports_argv0(system_bwrap_path: &Path) -> bool { + // bubblewrap added `--argv0` in v0.9.0: + // https://github.com/containers/bubblewrap/releases/tag/v0.9.0 + let output = match Command::new(system_bwrap_path).arg("--help").output() { + Ok(output) => output, + Err(_) => return false, + }; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + stdout.contains("--argv0") || stderr.contains("--argv0") +} + fn resolve_sqlite_home_env(resolved_cwd: &Path) -> Option { let raw = std::env::var(codex_state::SQLITE_HOME_ENV).ok()?; let trimmed = raw.trim(); @@ -651,9 +678,6 @@ impl ConfigBuilder { fallback_cwd, } = self; let codex_home = codex_home.map_or_else(find_codex_home, std::io::Result::Ok)?; - if let Err(err) = maybe_migrate_smart_approvals_alias(&codex_home).await { - tracing::warn!(error = %err, "failed to migrate smart_approvals feature alias"); - } let cli_overrides = cli_overrides.unwrap_or_default(); let mut harness_overrides = harness_overrides.unwrap_or_default(); let loader_overrides = loader_overrides.unwrap_or_default(); @@ -701,111 +725,6 @@ impl ConfigBuilder { } } -fn config_scope_segments(scope: &[String], key: &str) -> Vec { - let mut segments = scope.to_vec(); - segments.push(key.to_string()); - segments -} - -fn feature_scope_segments(scope: &[String], feature_key: &str) -> Vec { - let mut segments = scope.to_vec(); - segments.push("features".to_string()); - segments.push(feature_key.to_string()); - segments -} - -fn push_smart_approvals_alias_migration_edits( - edits: &mut Vec, - scope: &[String], - features: &FeaturesToml, - approvals_reviewer_missing: bool, -) { - let Some(alias_enabled) = features.entries.get("smart_approvals").copied() else { - return; - }; - let canonical_enabled = features - .entries - .get("guardian_approval") - .copied() - .unwrap_or(alias_enabled); - - if !features.entries.contains_key("guardian_approval") { - edits.push(ConfigEdit::SetPath { - segments: feature_scope_segments(scope, "guardian_approval"), - value: value(alias_enabled), - }); - } - if canonical_enabled && approvals_reviewer_missing { - edits.push(ConfigEdit::SetPath { - segments: config_scope_segments(scope, "approvals_reviewer"), - value: value(ApprovalsReviewer::GuardianSubagent.to_string()), - }); - } - edits.push(ConfigEdit::ClearPath { - segments: feature_scope_segments(scope, "smart_approvals"), - }); -} - -/// Rewrites the legacy `smart_approvals` feature flag to -/// `guardian_approval` in `config.toml` before normal config loading. -/// -/// If the old key is present, this preserves its value by setting -/// `guardian_approval = ` when the new key is not already present. -/// Because the deprecated flag historically meant "turn guardian review on", -/// this migration also backfills `approvals_reviewer = "guardian_subagent"` -/// in the same scope when that reviewer is not already configured there and the -/// migrated feature value is `true`. -/// In all cases it removes the deprecated `smart_approvals` entry so future -/// loads only see the canonical feature flag name. -async fn maybe_migrate_smart_approvals_alias(codex_home: &Path) -> std::io::Result { - let config_path = codex_home.join(CONFIG_TOML_FILE); - if !tokio::fs::try_exists(&config_path).await? { - return Ok(false); - } - - let config_contents = tokio::fs::read_to_string(&config_path).await?; - let Ok(config_toml) = toml::from_str::(&config_contents) else { - return Ok(false); - }; - - let mut edits = Vec::new(); - - let root_scope = Vec::new(); - if let Some(features) = config_toml.features.as_ref() { - push_smart_approvals_alias_migration_edits( - &mut edits, - &root_scope, - features, - config_toml.approvals_reviewer.is_none(), - ); - } - - for (profile_name, profile) in &config_toml.profiles { - if let Some(features) = profile.features.as_ref() { - let scope = vec!["profiles".to_string(), profile_name.clone()]; - push_smart_approvals_alias_migration_edits( - &mut edits, - &scope, - features, - profile.approvals_reviewer.is_none(), - ); - } - } - - if edits.is_empty() { - return Ok(false); - } - - ConfigEditsBuilder::new(codex_home) - .with_edits(edits) - .apply() - .await - .map_err(|err| { - std::io::Error::other(format!("failed to migrate guardian_approval alias: {err}")) - })?; - Ok(true) -} - impl Config { /// This is the preferred way to create an instance of [Config]. pub async fn load_with_cli_overrides( @@ -867,9 +786,6 @@ pub async fn load_config_as_toml_with_cli_overrides( cwd: &AbsolutePathBuf, cli_overrides: Vec<(String, TomlValue)>, ) -> std::io::Result { - if let Err(err) = maybe_migrate_smart_approvals_alias(codex_home).await { - tracing::warn!(error = %err, "failed to migrate smart_approvals feature alias"); - } let config_layer_stack = load_config_layers_state( codex_home, Some(cwd.clone()), @@ -1918,29 +1834,6 @@ fn resolve_permission_config_syntax( }) } -fn add_additional_file_system_writes( - file_system_sandbox_policy: &mut FileSystemSandboxPolicy, - additional_writable_roots: &[AbsolutePathBuf], -) { - for path in additional_writable_roots { - let exists = file_system_sandbox_policy.entries.iter().any(|entry| { - matches!( - &entry.path, - codex_protocol::permissions::FileSystemPath::Path { path: existing } - if existing == path && entry.access == codex_protocol::permissions::FileSystemAccessMode::Write - ) - }); - if !exists { - file_system_sandbox_policy.entries.push( - codex_protocol::permissions::FileSystemSandboxEntry { - path: codex_protocol::permissions::FileSystemPath::Path { path: path.clone() }, - access: codex_protocol::permissions::FileSystemAccessMode::Write, - }, - ); - } - } -} - /// Optional overrides for user configuration (e.g., from CLI flags). #[derive(Default, Debug, Clone)] pub struct ConfigOverrides { @@ -2309,10 +2202,8 @@ impl Config { let mut sandbox_policy = file_system_sandbox_policy .to_legacy_sandbox_policy(network_sandbox_policy, &resolved_cwd)?; if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) { - add_additional_file_system_writes( - &mut file_system_sandbox_policy, - &additional_writable_roots, - ); + file_system_sandbox_policy = file_system_sandbox_policy + .with_additional_writable_roots(&resolved_cwd, &additional_writable_roots); sandbox_policy = file_system_sandbox_policy .to_legacy_sandbox_policy(network_sandbox_policy, &resolved_cwd)?; } @@ -2663,6 +2554,11 @@ impl Config { } else { network.enabled().then_some(network) }; + let helper_readable_roots = get_readable_roots_required_for_codex_runtime( + &codex_home, + zsh_path.as_ref(), + main_execve_wrapper_exe.as_ref(), + ); let effective_sandbox_policy = constrained_sandbox_policy.value.get().clone(); let effective_file_system_sandbox_policy = if effective_sandbox_policy == original_sandbox_policy { @@ -2673,6 +2569,8 @@ impl Config { &resolved_cwd, ) }; + let effective_file_system_sandbox_policy = effective_file_system_sandbox_policy + .with_additional_readable_roots(&resolved_cwd, &helper_readable_roots); let effective_network_sandbox_policy = if effective_sandbox_policy == original_sandbox_policy { network_sandbox_policy diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index 4d1027efe0..759c269b76 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -190,6 +190,37 @@ pub(crate) fn compile_permission_profile( )) } +/// Returns a list of paths that must be readable by shell tools in order +/// for Codex to function. These should always be added to the +/// `FileSystemSandboxPolicy` for a thread. +pub(crate) fn get_readable_roots_required_for_codex_runtime( + codex_home: &Path, + zsh_path: Option<&PathBuf>, + main_execve_wrapper_exe: Option<&PathBuf>, +) -> Vec { + let arg0_root = AbsolutePathBuf::from_absolute_path(codex_home.join("tmp").join("arg0")).ok(); + let zsh_path = zsh_path.and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok()); + let execve_wrapper_root = main_execve_wrapper_exe.and_then(|path| { + let path = AbsolutePathBuf::from_absolute_path(path).ok()?; + if let Some(arg0_root) = arg0_root.as_ref() + && path.as_path().starts_with(arg0_root.as_path()) + { + path.parent() + } else { + Some(path) + } + }); + + let mut readable_roots = Vec::new(); + if let Some(zsh_path) = zsh_path { + readable_roots.push(zsh_path); + } + if let Some(execve_wrapper_root) = execve_wrapper_root { + readable_roots.push(execve_wrapper_root); + } + readable_roots +} + fn compile_network_sandbox_policy(network: Option<&NetworkToml>) -> NetworkSandboxPolicy { let Some(network) = network else { return NetworkSandboxPolicy::Restricted; diff --git a/codex-rs/core/src/config/permissions_tests.rs b/codex-rs/core/src/config/permissions_tests.rs index 036c8450cd..b90b903e88 100644 --- a/codex-rs/core/src/config/permissions_tests.rs +++ b/codex-rs/core/src/config/permissions_tests.rs @@ -1,5 +1,11 @@ use super::*; +use crate::config::Config; +use crate::config::ConfigOverrides; +use crate::config::ConfigToml; +use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; +use std::collections::BTreeMap; +use tempfile::TempDir; #[test] fn normalize_absolute_path_for_platform_simplifies_windows_verbatim_paths() { @@ -7,3 +13,66 @@ fn normalize_absolute_path_for_platform_simplifies_windows_verbatim_paths() { normalize_absolute_path_for_platform(r"\\?\D:\c\x\worktrees\2508\swift-base", true); assert_eq!(parsed, PathBuf::from(r"D:\c\x\worktrees\2508\swift-base")); } + +#[test] +fn restricted_read_implicitly_allows_helper_executables() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let cwd = temp_dir.path().join("workspace"); + let codex_home = temp_dir.path().join(".codex"); + let zsh_path = temp_dir.path().join("runtime").join("zsh"); + let arg0_root = codex_home.join("tmp").join("arg0"); + let allowed_arg0_dir = arg0_root.join("codex-arg0-session"); + let sibling_arg0_dir = arg0_root.join("codex-arg0-other-session"); + let execve_wrapper = allowed_arg0_dir.join("codex-execve-wrapper"); + std::fs::create_dir_all(&cwd)?; + std::fs::create_dir_all(zsh_path.parent().expect("zsh path should have parent"))?; + std::fs::create_dir_all(&allowed_arg0_dir)?; + std::fs::create_dir_all(&sibling_arg0_dir)?; + std::fs::write(&zsh_path, "")?; + std::fs::write(&execve_wrapper, "")?; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + default_permissions: Some("workspace".to_string()), + permissions: Some(PermissionsToml { + entries: BTreeMap::from([( + "workspace".to_string(), + PermissionProfileToml { + filesystem: Some(FilesystemPermissionsToml { + entries: BTreeMap::new(), + }), + network: None, + }, + )]), + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.clone()), + zsh_path: Some(zsh_path.clone()), + main_execve_wrapper_exe: Some(execve_wrapper), + ..Default::default() + }, + codex_home, + )?; + + let expected_zsh = AbsolutePathBuf::try_from(zsh_path)?; + let expected_allowed_arg0_dir = AbsolutePathBuf::try_from(allowed_arg0_dir)?; + let expected_sibling_arg0_dir = AbsolutePathBuf::try_from(sibling_arg0_dir)?; + let policy = &config.permissions.file_system_sandbox_policy; + + assert!( + policy.can_read_path_with_cwd(expected_zsh.as_path(), &cwd), + "expected zsh helper path to be readable, policy: {policy:?}" + ); + assert!( + policy.can_read_path_with_cwd(expected_allowed_arg0_dir.as_path(), &cwd), + "expected active arg0 helper dir to be readable, policy: {policy:?}" + ); + assert!( + !policy.can_read_path_with_cwd(expected_sibling_arg0_dir.as_path(), &cwd), + "expected sibling arg0 helper dir to remain unreadable, policy: {policy:?}" + ); + + Ok(()) +} diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index f990a80dce..6ed0370481 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -1,3 +1,4 @@ +use crate::agent::inter_agent_instruction::InterAgentInstruction; use crate::codex::TurnContext; use crate::context_manager::normalize; use crate::event_mapping::is_contextual_user_message_content; @@ -204,9 +205,10 @@ impl ContextManager { } } - /// Drop the last `num_turns` user turns from this history. + /// Drop the last `num_turns` instruction turns from this history. /// - /// "User turns" are identified as `ResponseItem::Message` entries whose role is `"user"`. + /// Instruction turns are history messages that should behave like a new prompt boundary: + /// ordinary user messages and structured assistant inter-agent instructions. /// /// This mirrors thread-rollback semantics: /// - `num_turns == 0` is a no-op @@ -248,7 +250,7 @@ impl ContextManager { } fn get_non_last_reasoning_items_tokens(&self) -> i64 { - // Get reasoning items excluding all the ones after the last user message. + // Get reasoning items excluding all the ones after the last instruction boundary. let Some(last_user_index) = self.items.iter().rposition(is_user_turn_boundary) else { return 0; }; @@ -631,7 +633,12 @@ pub(crate) fn is_user_turn_boundary(item: &ResponseItem) -> bool { return false; }; - role == "user" && !is_contextual_user_message_content(content) + (role == "user" && !is_contextual_user_message_content(content)) + || (role == "assistant" && is_inter_agent_instruction_content(content)) +} + +fn is_inter_agent_instruction_content(content: &[ContentItem]) -> bool { + InterAgentInstruction::is_message_content(content) } fn user_message_positions(items: &[ResponseItem]) -> Vec { diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index d91c168d00..dd9941ae50 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -39,6 +39,18 @@ fn assistant_msg(text: &str) -> ResponseItem { } } +fn inter_agent_assistant_msg(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: text.to_string(), + }], + end_turn: None, + phase: None, + } +} + fn create_history_with_items(items: Vec) -> ContextManager { let mut h = ContextManager::new(); // Use a generous but fixed token budget; tests only rely on truncation @@ -231,6 +243,35 @@ fn items_after_last_model_generated_tokens_are_zero_without_model_generated_item ); } +#[test] +fn inter_agent_assistant_messages_are_turn_boundaries() { + let item = inter_agent_assistant_msg( + "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue", + ); + + assert!(is_user_turn_boundary(&item)); +} + +#[test] +fn drop_last_n_user_turns_treats_inter_agent_assistant_messages_as_instruction_turns() { + let first_turn = user_input_text_msg("first"); + let first_reply = assistant_msg("done"); + let inter_agent_turn = inter_agent_assistant_msg( + "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue", + ); + let inter_agent_reply = assistant_msg("worker reply"); + let mut history = create_history_with_items(vec![ + first_turn.clone(), + first_reply.clone(), + inter_agent_turn, + inter_agent_reply, + ]); + + history.drop_last_n_user_turns(1); + + assert_eq!(history.raw_items(), &vec![first_turn, first_reply]); +} + #[test] fn total_token_usage_includes_all_items_after_last_model_generated_item() { let mut history = create_history_with_items(vec![assistant_msg("already counted by API")]); diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index 64a481b3f4..f4f257f2cf 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -43,6 +43,7 @@ fn build_permissions_update_item( Some(DeveloperInstructions::from_policy( next.sandbox_policy.get(), next.approval_policy.value(), + next.config.approvals_reviewer, exec_policy, &next.cwd, next.features.enabled(Feature::ExecPermissionApprovals), diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index ad776d1424..8f80547a4c 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -72,7 +72,7 @@ fn parse_agent_message( let mut content: Vec = Vec::new(); for content_item in message.iter() { match content_item { - ContentItem::OutputText { text } => { + ContentItem::InputText { text } | ContentItem::OutputText { text } => { content.push(AgentMessageContent::Text { text: text.clone() }); } _ => { diff --git a/codex-rs/core/src/event_mapping_tests.rs b/codex-rs/core/src/event_mapping_tests.rs index befd9463bd..ce36ad2976 100644 --- a/codex-rs/core/src/event_mapping_tests.rs +++ b/codex-rs/core/src/event_mapping_tests.rs @@ -97,6 +97,43 @@ fn skips_local_image_label_text() { } } +#[test] +fn parses_assistant_message_input_text_for_backward_compatibility() { + let item = ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::InputText { + text: "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue" + .to_string(), + }], + end_turn: None, + phase: None, + }; + + let turn_item = parse_turn_item(&item).expect("expected assistant message turn item"); + + match turn_item { + TurnItem::AgentMessage(message) => { + let rendered = message + .content + .into_iter() + .map(|content| { + let AgentMessageContent::Text { text } = content; + text + }) + .collect::>(); + assert_eq!( + rendered, + vec![ + "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue" + .to_string() + ] + ); + } + other => panic!("expected TurnItem::AgentMessage, got {other:?}"), + } +} + #[test] fn skips_unnamed_image_label_text() { let image_url = "data:image/png;base64,abc".to_string(); diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 0c95af4c02..a027f2461b 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -549,7 +549,7 @@ pub fn render_decision_for_unmatched_command( // On Windows, ReadOnly sandbox is not a real sandbox, so special-case it // here. - let runtime_sandbox_provides_safety = + let environment_lacks_sandbox_protections = cfg!(windows) && matches!(sandbox_policy, SandboxPolicy::ReadOnly { .. }); // If the command is flagged as dangerous or we have no sandbox protection, @@ -558,9 +558,20 @@ pub fn render_decision_for_unmatched_command( // We prefer to prompt the user rather than outright forbid the command, // but if the user has explicitly disabled prompts, we must // forbid the command. - if command_might_be_dangerous(command) || runtime_sandbox_provides_safety { + if command_might_be_dangerous(command) || environment_lacks_sandbox_protections { return match approval_policy { - AskForApproval::Never => Decision::Forbidden, + AskForApproval::Never => { + let sandbox_is_explicitly_disabled = matches!( + sandbox_policy, + SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } + ); + if sandbox_is_explicitly_disabled { + // If the sandbox is explicitly disabled, we should allow the command to run + Decision::Allow + } else { + Decision::Forbidden + } + } AskForApproval::OnFailure | AskForApproval::OnRequest | AskForApproval::UnlessTrusted diff --git a/codex-rs/core/src/exec_policy_tests.rs b/codex-rs/core/src/exec_policy_tests.rs index fd3fe05e11..f062bcc741 100644 --- a/codex-rs/core/src/exec_policy_tests.rs +++ b/codex-rs/core/src/exec_policy_tests.rs @@ -81,6 +81,10 @@ fn unrestricted_file_system_sandbox_policy() -> FileSystemSandboxPolicy { FileSystemSandboxPolicy::unrestricted() } +fn external_file_system_sandbox_policy() -> FileSystemSandboxPolicy { + FileSystemSandboxPolicy::external_sandbox() +} + async fn test_config() -> (TempDir, Config) { let home = TempDir::new().expect("create temp dir"); let config = ConfigBuilder::default() @@ -503,39 +507,25 @@ async fn loads_policies_from_multiple_config_layers() -> anyhow::Result<()> { #[tokio::test] async fn evaluates_bash_lc_inner_commands() { - let policy_src = r#" -prefix_rule(pattern=["rm"], decision="forbidden") -"#; - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", policy_src) - .expect("parse policy"); - let policy = Arc::new(parser.build()); - - let forbidden_script = vec![ - "bash".to_string(), - "-lc".to_string(), - "rm -rf /some/important/folder".to_string(), - ]; - - let manager = ExecPolicyManager::new(policy); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &forbidden_script, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: Some(r#"prefix_rule(pattern=["rm"], decision="forbidden")"#.to_string()), + command: vec![ + "bash".to_string(), + "-lc".to_string(), + "rm -rf /some/important/folder".to_string(), + ], approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::DangerFullAccess, - file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::DangerFullAccess, + file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, - ExecApprovalRequirement::Forbidden { - reason: "`bash -lc 'rm -rf /some/important/folder'` rejected: policy forbids commands starting with `rm`".to_string() - } - ); + }, + ExecApprovalRequirement::Forbidden { + reason: "`bash -lc 'rm -rf /some/important/folder'` rejected: policy forbids commands starting with `rm`".to_string(), + }, + ) + .await; } #[test] @@ -558,163 +548,132 @@ fn commands_for_exec_policy_falls_back_for_whitespace_shell_script() { #[tokio::test] async fn evaluates_heredoc_script_against_prefix_rules() { - let policy_src = r#"prefix_rule(pattern=["python3"], decision="allow")"#; - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", policy_src) - .expect("parse policy"); - let policy = Arc::new(parser.build()); let command = vec![ "bash".to_string(), "-lc".to_string(), "python3 <<'PY'\nprint('hello')\nPY".to_string(), ]; - let requirement = ExecPolicyManager::new(policy) - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: Some(r#"prefix_rule(pattern=["python3"], decision="allow")"#.to_string()), + command, approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::Skip { bypass_sandbox: true, proposed_execpolicy_amendment: None, - } - ); + }, + ) + .await; } #[tokio::test] async fn omits_auto_amendment_for_heredoc_fallback_prompts() { - let command = vec![ - "bash".to_string(), - "-lc".to_string(), - "python3 <<'PY'\nprint('hello')\nPY".to_string(), - ]; - - let requirement = ExecPolicyManager::default() - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: None, + command: vec![ + "bash".to_string(), + "-lc".to_string(), + "python3 <<'PY'\nprint('hello')\nPY".to_string(), + ], approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::NeedsApproval { reason: None, proposed_execpolicy_amendment: None, - } - ); + }, + ) + .await; } #[tokio::test] async fn drops_requested_amendment_for_heredoc_fallback_prompts_when_it_wont_match() { - let command = vec![ - "bash".to_string(), - "-lc".to_string(), - "python3 <<'PY'\nprint('hello')\nPY".to_string(), - ]; - let requested_prefix = vec!["python3".to_string(), "-m".to_string(), "pip".to_string()]; - - let requirement = ExecPolicyManager::default() - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: None, + command: vec![ + "bash".to_string(), + "-lc".to_string(), + "python3 <<'PY'\nprint('hello')\nPY".to_string(), + ], approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, - prefix_rule: Some(requested_prefix.clone()), - }) - .await; - - assert_eq!( - requirement, + prefix_rule: Some(vec![ + "python3".to_string(), + "-m".to_string(), + "pip".to_string(), + ]), + }, ExecApprovalRequirement::NeedsApproval { reason: None, proposed_execpolicy_amendment: None, - } - ); + }, + ) + .await; } #[tokio::test] async fn justification_is_included_in_forbidden_exec_approval_requirement() { - let policy_src = r#" + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: Some( + r#" prefix_rule( pattern=["rm"], decision="forbidden", justification="destructive command", ) -"#; - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", policy_src) - .expect("parse policy"); - let policy = Arc::new(parser.build()); - - let manager = ExecPolicyManager::new(policy); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &[ +"# + .to_string(), + ), + command: vec![ "rm".to_string(), "-rf".to_string(), "/some/important/folder".to_string(), ], approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::DangerFullAccess, - file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::DangerFullAccess, + file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::Forbidden { - reason: "`rm -rf /some/important/folder` rejected: destructive command".to_string() - } - ); + reason: "`rm -rf /some/important/folder` rejected: destructive command".to_string(), + }, + ) + .await; } #[tokio::test] async fn exec_approval_requirement_prefers_execpolicy_match() { - let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#; - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", policy_src) - .expect("parse policy"); - let policy = Arc::new(parser.build()); - let command = vec!["rm".to_string()]; - - let manager = ExecPolicyManager::new(policy); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: Some(r#"prefix_rule(pattern=["rm"], decision="prompt")"#.to_string()), + command: vec!["rm".to_string()], approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::DangerFullAccess, - file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::DangerFullAccess, + file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::NeedsApproval { reason: Some("`rm` requires approval by policy".to_string()), proposed_execpolicy_amendment: None, - } - ); + }, + ) + .await; } #[tokio::test] @@ -727,31 +686,22 @@ host_executable(name = "git", paths = ["{git_path_literal}"]) prefix_rule(pattern=["git"], decision="allow") "# ); - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", &policy_src) - .expect("parse policy"); - let manager = ExecPolicyManager::new(Arc::new(parser.build())); - let command = vec![git_path, "status".to_string()]; - - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: Some(policy_src), + command: vec![git_path, "status".to_string()], approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::Skip { bypass_sandbox: true, proposed_execpolicy_amendment: None, - } - ); + }, + ) + .await; } #[tokio::test] @@ -770,93 +720,71 @@ host_executable(name = "git", paths = ["{allowed_git_path_literal}"]) prefix_rule(pattern=["git"], decision="prompt") "# ); - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", &policy_src) - .expect("parse policy"); - let manager = ExecPolicyManager::new(Arc::new(parser.build())); - let command = vec![disallowed_git_path, "status".to_string()]; - - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: Some(policy_src), + command: vec![disallowed_git_path.clone(), "status".to_string()], approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::Skip { bypass_sandbox: false, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)), - } - ); + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + disallowed_git_path, + "status".to_string(), + ])), + }, + ) + .await; } #[tokio::test] async fn requested_prefix_rule_can_approve_absolute_path_commands() { - let command = vec![ - host_program_path("cargo"), - "install".to_string(), - "cargo-insta".to_string(), - ]; - let manager = ExecPolicyManager::default(); - - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: None, + command: vec![ + host_program_path("cargo"), + "install".to_string(), + "cargo-insta".to_string(), + ], approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::NeedsApproval { reason: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ "cargo".to_string(), "install".to_string(), ])), - } - ); + }, + ) + .await; } #[tokio::test] async fn exec_approval_requirement_respects_approval_policy() { - let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#; - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", policy_src) - .expect("parse policy"); - let policy = Arc::new(parser.build()); - let command = vec!["rm".to_string()]; - - let manager = ExecPolicyManager::new(policy); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: Some(r#"prefix_rule(pattern=["rm"], decision="prompt")"#.to_string()), + command: vec!["rm".to_string()], approval_policy: AskForApproval::Never, - sandbox_policy: &SandboxPolicy::DangerFullAccess, - file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::DangerFullAccess, + file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::Forbidden { - reason: PROMPT_CONFLICT_REASON.to_string() - } - ); + reason: PROMPT_CONFLICT_REASON.to_string(), + }, + ) + .await; } #[test] @@ -903,11 +831,10 @@ fn unmatched_on_request_uses_split_filesystem_policy_for_escalation_prompts() { #[tokio::test] async fn exec_approval_requirement_rejects_unmatched_sandbox_escalation_when_granular_sandbox_is_disabled() { - let command = vec!["madeup-cmd".to_string()]; - - let requirement = ExecPolicyManager::default() - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: None, + command: vec!["madeup-cmd".to_string()], approval_policy: AskForApproval::Granular(GranularApprovalConfig { sandbox_approval: false, rules: true, @@ -915,19 +842,16 @@ async fn exec_approval_requirement_rejects_unmatched_sandbox_escalation_when_gra request_permissions: true, mcp_elicitations: true, }), - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::Forbidden { reason: REJECT_SANDBOX_APPROVAL_REASON.to_string(), - } - ); + }, + ) + .await; } #[tokio::test] @@ -1239,178 +1163,140 @@ async fn append_execpolicy_amendment_rejects_empty_prefix() { async fn proposed_execpolicy_amendment_is_present_for_single_command_without_policy_match() { let command = vec!["cargo".to_string(), "build".to_string()]; - let manager = ExecPolicyManager::default(); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: None, + command: command.clone(), approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::NeedsApproval { reason: None, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)) - } - ); + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)), + }, + ) + .await; } #[tokio::test] async fn proposed_execpolicy_amendment_is_omitted_when_policy_prompts() { - let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#; - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", policy_src) - .expect("parse policy"); - let policy = Arc::new(parser.build()); - let command = vec!["rm".to_string()]; - - let manager = ExecPolicyManager::new(policy); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: Some(r#"prefix_rule(pattern=["rm"], decision="prompt")"#.to_string()), + command: vec!["rm".to_string()], approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::DangerFullAccess, - file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::DangerFullAccess, + file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::NeedsApproval { reason: Some("`rm` requires approval by policy".to_string()), proposed_execpolicy_amendment: None, - } - ); + }, + ) + .await; } #[tokio::test] async fn proposed_execpolicy_amendment_is_present_for_multi_command_scripts() { - let command = vec![ - "bash".to_string(), - "-lc".to_string(), - "cargo build && echo ok".to_string(), - ]; - let manager = ExecPolicyManager::default(); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: None, + command: vec![ + "bash".to_string(), + "-lc".to_string(), + "cargo build && echo ok".to_string(), + ], approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::NeedsApproval { reason: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ "cargo".to_string(), - "build".to_string() + "build".to_string(), ])), - } - ); + }, + ) + .await; } #[tokio::test] async fn proposed_execpolicy_amendment_uses_first_no_match_in_multi_command_scripts() { let policy_src = r#"prefix_rule(pattern=["cat"], decision="allow")"#; - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", policy_src) - .expect("parse policy"); - let policy = Arc::new(parser.build()); - let command = vec![ "bash".to_string(), "-lc".to_string(), "cat && apple".to_string(), ]; - assert_eq!( - ExecPolicyManager::new(policy) - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, - approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), - sandbox_permissions: SandboxPermissions::UseDefault, - prefix_rule: None, - }) - .await, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: Some(policy_src.to_string()), + command, + approval_policy: AskForApproval::UnlessTrusted, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: None, + }, ExecApprovalRequirement::NeedsApproval { reason: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ - "apple".to_string() + "apple".to_string(), ])), - } - ); + }, + ) + .await; } #[tokio::test] async fn proposed_execpolicy_amendment_is_present_when_heuristics_allow() { let command = vec!["echo".to_string(), "safe".to_string()]; - let manager = ExecPolicyManager::default(); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: None, + command: command.clone(), approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::Skip { bypass_sandbox: false, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)), - } - ); + }, + ) + .await; } #[tokio::test] async fn proposed_execpolicy_amendment_is_suppressed_when_policy_matches_allow() { - let policy_src = r#"prefix_rule(pattern=["echo"], decision="allow")"#; - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", policy_src) - .expect("parse policy"); - let policy = Arc::new(parser.build()); - let command = vec!["echo".to_string(), "safe".to_string()]; - - let manager = ExecPolicyManager::new(policy); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: Some(r#"prefix_rule(pattern=["echo"], decision="allow")"#.to_string()), + command: vec!["echo".to_string(), "safe".to_string()], approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::Skip { bypass_sandbox: true, proposed_execpolicy_amendment: None, - } - ); + }, + ) + .await; } fn derive_requested_execpolicy_amendment_for_test( @@ -1560,25 +1446,23 @@ fn derive_requested_execpolicy_amendment_returns_none_when_policy_matches() { #[tokio::test] async fn dangerous_rm_rf_requires_approval_in_danger_full_access() { let command = vec_str(&["rm", "-rf", "/tmp/nonexistent"]); - let manager = ExecPolicyManager::default(); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: None, + command: command.clone(), approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::DangerFullAccess, - file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::DangerFullAccess, + file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::NeedsApproval { reason: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)), - } - ); + }, + ) + .await; } fn vec_str(items: &[&str]) -> Vec { @@ -1686,3 +1570,100 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() { (unless AskForApproval::Never is specified)."# ); } + +#[tokio::test] +async fn dangerous_command_allowed_when_sandbox_is_explicitly_disabled() { + let command = vec_str(&["rm", "-rf", "/tmp/nonexistent"]); + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: None, + command, + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ExternalSandbox { + network_access: Default::default(), + }, + file_system_sandbox_policy: external_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: None, + }, + ExecApprovalRequirement::Skip { + bypass_sandbox: false, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment { + command: vec_str(&["rm", "-rf", "/tmp/nonexistent"]), + }), + }, + ) + .await; +} + +#[tokio::test] +async fn dangerous_command_forbidden_in_external_sandbox_when_policy_matches() { + let command = vec_str(&["rm", "-rf", "/tmp/nonexistent"]); + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: Some("prefix_rule(pattern=['rm'], decision='prompt')".to_string()), + command, + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ExternalSandbox { + network_access: Default::default(), + }, + file_system_sandbox_policy: external_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: None, + }, + ExecApprovalRequirement::Forbidden { + reason: "approval required by policy, but AskForApproval is set to Never".to_string(), + }, + ) + .await; +} + +struct ExecApprovalRequirementScenario { + /// Source for the Starlark `.rules` file. + policy_src: Option, + command: Vec, + approval_policy: AskForApproval, + sandbox_policy: SandboxPolicy, + file_system_sandbox_policy: FileSystemSandboxPolicy, + sandbox_permissions: SandboxPermissions, + prefix_rule: Option>, +} + +async fn assert_exec_approval_requirement_for_command( + test: ExecApprovalRequirementScenario, + expected_requirement: ExecApprovalRequirement, +) { + let ExecApprovalRequirementScenario { + policy_src, + command, + approval_policy, + sandbox_policy, + file_system_sandbox_policy, + sandbox_permissions, + prefix_rule, + } = test; + + let policy = match policy_src { + Some(src) => { + let mut parser = PolicyParser::new(); + parser + .parse("test.rules", src.as_str()) + .expect("parse policy"); + Arc::new(parser.build()) + } + None => Arc::new(Policy::empty()), + }; + + let requirement = ExecPolicyManager::new(policy) + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: &command, + approval_policy, + sandbox_policy: &sandbox_policy, + file_system_sandbox_policy: &file_system_sandbox_policy, + sandbox_permissions, + prefix_rule, + }) + .await; + + assert_eq!(requirement, expected_requirement); +} diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 3e7f0cb84f..f82cc73bb5 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -457,6 +457,9 @@ const MCP_TOOL_APPROVAL_TOOL_TITLE_KEY: &str = "tool_title"; const MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY: &str = "tool_description"; const MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY: &str = "tool_params"; const MCP_TOOL_APPROVAL_TOOL_PARAMS_DISPLAY_KEY: &str = "tool_params_display"; +const MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_DEFAULT: &str = "mcp_tool_call__default"; +const MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_ALWAYS_ALLOW: &str = "mcp_tool_call__always_allow"; +const MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_FULL_ACCESS: &str = "mcp_tool_call__full_access"; pub(crate) fn is_mcp_tool_approval_question_id(question_id: &str) -> bool { question_id @@ -494,14 +497,22 @@ async fn maybe_request_mcp_tool_approval( let annotations = metadata.and_then(|metadata| metadata.annotations.as_ref()); let approval_required = annotations.is_some_and(requires_mcp_tool_approval); let mut monitor_reason = None; + let auto_approved_by_policy = approval_mode == AppToolApproval::Approve + || (approval_mode == AppToolApproval::Auto && is_full_access_mode(turn_context)); - if approval_mode == AppToolApproval::Approve { + if auto_approved_by_policy { if !approval_required { return None; } - match maybe_monitor_auto_approved_mcp_tool_call(sess, turn_context, invocation, metadata) - .await + match maybe_monitor_auto_approved_mcp_tool_call( + sess, + turn_context, + invocation, + metadata, + approval_mode, + ) + .await { ArcMonitorOutcome::Ok => return None, ArcMonitorOutcome::AskUser(reason) => { @@ -515,13 +526,8 @@ async fn maybe_request_mcp_tool_approval( } } - if approval_mode == AppToolApproval::Auto { - if is_full_access_mode(turn_context) { - return None; - } - if !approval_required { - return None; - } + if approval_mode == AppToolApproval::Auto && !approval_required { + return None; } let session_approval_key = session_mcp_tool_approval_key(invocation, metadata, approval_mode); @@ -653,9 +659,16 @@ async fn maybe_monitor_auto_approved_mcp_tool_call( turn_context: &TurnContext, invocation: &McpInvocation, metadata: Option<&McpToolApprovalMetadata>, + approval_mode: AppToolApproval, ) -> ArcMonitorOutcome { let action = prepare_arc_request_action(invocation, metadata); - monitor_action(sess, turn_context, action).await + monitor_action( + sess, + turn_context, + action, + mcp_tool_approval_callsite_mode(approval_mode, turn_context), + ) + .await } fn prepare_arc_request_action( @@ -749,6 +762,22 @@ fn is_full_access_mode(turn_context: &TurnContext) -> bool { ) } +fn mcp_tool_approval_callsite_mode( + approval_mode: AppToolApproval, + turn_context: &TurnContext, +) -> &'static str { + match approval_mode { + AppToolApproval::Approve => MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_ALWAYS_ALLOW, + AppToolApproval::Auto | AppToolApproval::Prompt => { + if approval_mode == AppToolApproval::Auto && is_full_access_mode(turn_context) { + MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_FULL_ACCESS + } else { + MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_DEFAULT + } + } + } +} + pub(crate) async fn lookup_mcp_tool_metadata( sess: &Session, turn_context: &TurnContext, diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 5537e680ed..0ac37f9ce8 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -776,6 +776,38 @@ fn approval_elicitation_meta_merges_session_and_always_persist_with_connector_so ); } +#[tokio::test] +async fn approval_callsite_mode_distinguishes_default_always_allow_and_full_access() { + let (_session, mut turn_context) = make_session_and_context().await; + + assert_eq!( + mcp_tool_approval_callsite_mode(AppToolApproval::Auto, &turn_context), + "mcp_tool_call__default" + ); + assert_eq!( + mcp_tool_approval_callsite_mode(AppToolApproval::Prompt, &turn_context), + "mcp_tool_call__default" + ); + assert_eq!( + mcp_tool_approval_callsite_mode(AppToolApproval::Approve, &turn_context), + "mcp_tool_call__always_allow" + ); + + turn_context + .approval_policy + .set(AskForApproval::Never) + .expect("test setup should allow updating approval policy"); + turn_context + .sandbox_policy + .set(SandboxPolicy::DangerFullAccess) + .expect("test setup should allow updating sandbox policy"); + + assert_eq!( + mcp_tool_approval_callsite_mode(AppToolApproval::Auto, &turn_context), + "mcp_tool_call__full_access" + ); +} + #[test] fn declined_elicitation_response_stays_decline() { let response = parse_mcp_tool_approval_elicitation_response( @@ -1035,6 +1067,83 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_for_model() { ); } +#[tokio::test] +async fn full_access_auto_mode_blocks_when_arc_returns_interrupt_for_model() { + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/safety/arc")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "outcome": "steer-model", + "short_reason": "needs approval", + "rationale": "high-risk action", + "risk_score": 96, + "risk_level": "critical", + "evidence": [{ + "message": "dangerous_tool", + "why": "high-risk action", + }], + }))) + .expect(1) + .mount(&server) + .await; + + let (session, mut turn_context) = make_session_and_context().await; + turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth( + crate::CodexAuth::create_dummy_chatgpt_auth_for_testing(), + )); + turn_context + .approval_policy + .set(AskForApproval::Never) + .expect("test setup should allow updating approval policy"); + turn_context + .sandbox_policy + .set(SandboxPolicy::DangerFullAccess) + .expect("test setup should allow updating sandbox policy"); + let mut config = (*turn_context.config).clone(); + config.chatgpt_base_url = server.uri(); + turn_context.config = Arc::new(config); + + let session = Arc::new(session); + let turn_context = Arc::new(turn_context); + let invocation = McpInvocation { + server: CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool: "dangerous_tool".to_string(), + arguments: Some(serde_json::json!({ "id": 1 })), + }; + let metadata = McpToolApprovalMetadata { + annotations: Some(annotations(Some(false), Some(true), Some(true))), + connector_id: Some("calendar".to_string()), + connector_name: Some("Calendar".to_string()), + connector_description: Some("Manage events".to_string()), + tool_title: Some("Dangerous Tool".to_string()), + tool_description: Some("Performs a risky action.".to_string()), + codex_apps_meta: None, + }; + + let decision = maybe_request_mcp_tool_approval( + &session, + &turn_context, + "call-2", + &invocation, + Some(&metadata), + AppToolApproval::Auto, + ) + .await; + + assert_eq!( + decision, + Some(McpToolApprovalDecision::BlockedBySafetyMonitor( + "Tool call was cancelled because of safety risks: high-risk action".to_string(), + )) + ); +} + #[tokio::test] async fn approve_mode_routes_arc_ask_user_to_guardian_when_guardian_reviewer_is_enabled() { use wiremock::Mock; diff --git a/codex-rs/core/src/personality_migration_tests.rs b/codex-rs/core/src/personality_migration_tests.rs index fef1297a97..de1070ad34 100644 --- a/codex-rs/core/src/personality_migration_tests.rs +++ b/codex-rs/core/src/personality_migration_tests.rs @@ -38,6 +38,7 @@ async fn write_session_with_user_event(codex_home: &Path) -> io::Result<()> { originator: "test_originator".to_string(), cli_version: "test_version".to_string(), source: SessionSource::Cli, + agent_path: None, agent_nickname: None, agent_role: None, model_provider: None, diff --git a/codex-rs/core/src/plugins/curated_repo.rs b/codex-rs/core/src/plugins/curated_repo.rs deleted file mode 100644 index 3307f28ffc..0000000000 --- a/codex-rs/core/src/plugins/curated_repo.rs +++ /dev/null @@ -1,356 +0,0 @@ -use crate::default_client::build_reqwest_client; -use reqwest::Client; -use serde::Deserialize; -use std::fs; -use std::io::Cursor; -#[cfg(unix)] -use std::os::unix::fs::PermissionsExt; -use std::path::Component; -use std::path::Path; -use std::path::PathBuf; -use std::time::Duration; -use zip::ZipArchive; - -const GITHUB_API_BASE_URL: &str = "https://api.github.com"; -const GITHUB_API_ACCEPT_HEADER: &str = "application/vnd.github+json"; -const GITHUB_API_VERSION_HEADER: &str = "2022-11-28"; -const OPENAI_PLUGINS_OWNER: &str = "openai"; -const OPENAI_PLUGINS_REPO: &str = "plugins"; -const CURATED_PLUGINS_RELATIVE_DIR: &str = ".tmp/plugins"; -const CURATED_PLUGINS_SHA_FILE: &str = ".tmp/plugins.sha"; -const CURATED_PLUGINS_HTTP_TIMEOUT: Duration = Duration::from_secs(30); - -#[derive(Debug, Deserialize)] -struct GitHubRepositorySummary { - default_branch: String, -} - -#[derive(Debug, Deserialize)] -struct GitHubGitRefSummary { - object: GitHubGitRefObject, -} - -#[derive(Debug, Deserialize)] -struct GitHubGitRefObject { - sha: String, -} - -pub(crate) fn curated_plugins_repo_path(codex_home: &Path) -> PathBuf { - codex_home.join(CURATED_PLUGINS_RELATIVE_DIR) -} - -pub(crate) fn read_curated_plugins_sha(codex_home: &Path) -> Option { - read_sha_file(codex_home.join(CURATED_PLUGINS_SHA_FILE).as_path()) -} - -pub(crate) fn sync_openai_plugins_repo(codex_home: &Path) -> Result { - sync_openai_plugins_repo_with_api_base_url(codex_home, GITHUB_API_BASE_URL) -} - -fn sync_openai_plugins_repo_with_api_base_url( - codex_home: &Path, - api_base_url: &str, -) -> Result { - let repo_path = curated_plugins_repo_path(codex_home); - let sha_path = codex_home.join(CURATED_PLUGINS_SHA_FILE); - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|err| format!("failed to create curated plugins sync runtime: {err}"))?; - let remote_sha = runtime.block_on(fetch_curated_repo_remote_sha(api_base_url))?; - let local_sha = read_sha_file(&sha_path); - - if local_sha.as_deref() == Some(remote_sha.as_str()) && repo_path.is_dir() { - return Ok(remote_sha); - } - - let Some(parent) = repo_path.parent() else { - return Err(format!( - "failed to determine curated plugins parent directory for {}", - repo_path.display() - )); - }; - fs::create_dir_all(parent).map_err(|err| { - format!( - "failed to create curated plugins parent directory {}: {err}", - parent.display() - ) - })?; - - let clone_dir = tempfile::Builder::new() - .prefix("plugins-clone-") - .tempdir_in(parent) - .map_err(|err| { - format!( - "failed to create temporary curated plugins directory in {}: {err}", - parent.display() - ) - })?; - let cloned_repo_path = clone_dir.path().join("repo"); - let zipball_bytes = runtime.block_on(fetch_curated_repo_zipball(api_base_url, &remote_sha))?; - extract_zipball_to_dir(&zipball_bytes, &cloned_repo_path)?; - - if !cloned_repo_path - .join(".agents/plugins/marketplace.json") - .is_file() - { - return Err(format!( - "curated plugins archive missing marketplace manifest at {}", - cloned_repo_path - .join(".agents/plugins/marketplace.json") - .display() - )); - } - - if repo_path.exists() { - let backup_dir = tempfile::Builder::new() - .prefix("plugins-backup-") - .tempdir_in(parent) - .map_err(|err| { - format!( - "failed to create curated plugins backup directory in {}: {err}", - parent.display() - ) - })?; - let backup_repo_path = backup_dir.path().join("repo"); - - fs::rename(&repo_path, &backup_repo_path).map_err(|err| { - format!( - "failed to move previous curated plugins repo out of the way at {}: {err}", - repo_path.display() - ) - })?; - - if let Err(err) = fs::rename(&cloned_repo_path, &repo_path) { - let rollback_result = fs::rename(&backup_repo_path, &repo_path); - return match rollback_result { - Ok(()) => Err(format!( - "failed to activate new curated plugins repo at {}: {err}", - repo_path.display() - )), - Err(rollback_err) => { - let backup_path = backup_dir.keep().join("repo"); - Err(format!( - "failed to activate new curated plugins repo at {}: {err}; failed to restore previous repo (left at {}): {rollback_err}", - repo_path.display(), - backup_path.display() - )) - } - }; - } - } else { - fs::rename(&cloned_repo_path, &repo_path).map_err(|err| { - format!( - "failed to activate curated plugins repo at {}: {err}", - repo_path.display() - ) - })?; - } - - if let Some(parent) = sha_path.parent() { - fs::create_dir_all(parent).map_err(|err| { - format!( - "failed to create curated plugins sha directory {}: {err}", - parent.display() - ) - })?; - } - fs::write(&sha_path, format!("{remote_sha}\n")).map_err(|err| { - format!( - "failed to write curated plugins sha file {}: {err}", - sha_path.display() - ) - })?; - - Ok(remote_sha) -} - -async fn fetch_curated_repo_remote_sha(api_base_url: &str) -> Result { - let api_base_url = api_base_url.trim_end_matches('/'); - let repo_url = format!("{api_base_url}/repos/{OPENAI_PLUGINS_OWNER}/{OPENAI_PLUGINS_REPO}"); - let client = build_reqwest_client(); - let repo_body = fetch_github_text(&client, &repo_url, "get curated plugins repository").await?; - let repo_summary: GitHubRepositorySummary = - serde_json::from_str(&repo_body).map_err(|err| { - format!("failed to parse curated plugins repository response from {repo_url}: {err}") - })?; - if repo_summary.default_branch.is_empty() { - return Err(format!( - "curated plugins repository response from {repo_url} did not include a default branch" - )); - } - - let git_ref_url = format!("{repo_url}/git/ref/heads/{}", repo_summary.default_branch); - let git_ref_body = - fetch_github_text(&client, &git_ref_url, "get curated plugins HEAD ref").await?; - let git_ref: GitHubGitRefSummary = serde_json::from_str(&git_ref_body).map_err(|err| { - format!("failed to parse curated plugins ref response from {git_ref_url}: {err}") - })?; - if git_ref.object.sha.is_empty() { - return Err(format!( - "curated plugins ref response from {git_ref_url} did not include a HEAD sha" - )); - } - - Ok(git_ref.object.sha) -} - -async fn fetch_curated_repo_zipball( - api_base_url: &str, - remote_sha: &str, -) -> Result, String> { - let api_base_url = api_base_url.trim_end_matches('/'); - let repo_url = format!("{api_base_url}/repos/{OPENAI_PLUGINS_OWNER}/{OPENAI_PLUGINS_REPO}"); - let zipball_url = format!("{repo_url}/zipball/{remote_sha}"); - let client = build_reqwest_client(); - fetch_github_bytes(&client, &zipball_url, "download curated plugins archive").await -} - -async fn fetch_github_text(client: &Client, url: &str, context: &str) -> Result { - let response = github_request(client, url) - .send() - .await - .map_err(|err| format!("failed to {context} from {url}: {err}"))?; - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - if !status.is_success() { - return Err(format!( - "{context} from {url} failed with status {status}: {body}" - )); - } - Ok(body) -} - -async fn fetch_github_bytes(client: &Client, url: &str, context: &str) -> Result, String> { - let response = github_request(client, url) - .send() - .await - .map_err(|err| format!("failed to {context} from {url}: {err}"))?; - let status = response.status(); - let body = response - .bytes() - .await - .map_err(|err| format!("failed to read {context} response from {url}: {err}"))?; - if !status.is_success() { - let body_text = String::from_utf8_lossy(&body); - return Err(format!( - "{context} from {url} failed with status {status}: {body_text}" - )); - } - Ok(body.to_vec()) -} - -fn github_request(client: &Client, url: &str) -> reqwest::RequestBuilder { - client - .get(url) - .timeout(CURATED_PLUGINS_HTTP_TIMEOUT) - .header("accept", GITHUB_API_ACCEPT_HEADER) - .header("x-github-api-version", GITHUB_API_VERSION_HEADER) -} - -fn read_sha_file(sha_path: &Path) -> Option { - fs::read_to_string(sha_path) - .ok() - .map(|sha| sha.trim().to_string()) - .filter(|sha| !sha.is_empty()) -} - -fn extract_zipball_to_dir(bytes: &[u8], destination: &Path) -> Result<(), String> { - fs::create_dir_all(destination).map_err(|err| { - format!( - "failed to create curated plugins extraction directory {}: {err}", - destination.display() - ) - })?; - - let cursor = Cursor::new(bytes); - let mut archive = ZipArchive::new(cursor) - .map_err(|err| format!("failed to open curated plugins zip archive: {err}"))?; - - for index in 0..archive.len() { - let mut entry = archive - .by_index(index) - .map_err(|err| format!("failed to read curated plugins zip entry: {err}"))?; - let Some(relative_path) = entry.enclosed_name() else { - return Err(format!( - "curated plugins zip entry `{}` escapes extraction root", - entry.name() - )); - }; - - let mut components = relative_path.components(); - let Some(Component::Normal(_)) = components.next() else { - continue; - }; - - let output_relative = components.fold(PathBuf::new(), |mut path, component| { - if let Component::Normal(segment) = component { - path.push(segment); - } - path - }); - if output_relative.as_os_str().is_empty() { - continue; - } - - let output_path = destination.join(&output_relative); - if entry.is_dir() { - fs::create_dir_all(&output_path).map_err(|err| { - format!( - "failed to create curated plugins directory {}: {err}", - output_path.display() - ) - })?; - continue; - } - - if let Some(parent) = output_path.parent() { - fs::create_dir_all(parent).map_err(|err| { - format!( - "failed to create curated plugins directory {}: {err}", - parent.display() - ) - })?; - } - let mut output = fs::File::create(&output_path).map_err(|err| { - format!( - "failed to create curated plugins file {}: {err}", - output_path.display() - ) - })?; - std::io::copy(&mut entry, &mut output).map_err(|err| { - format!( - "failed to write curated plugins file {}: {err}", - output_path.display() - ) - })?; - apply_zip_permissions(&entry, &output_path)?; - } - - Ok(()) -} - -#[cfg(unix)] -fn apply_zip_permissions(entry: &zip::read::ZipFile<'_>, output_path: &Path) -> Result<(), String> { - let Some(mode) = entry.unix_mode() else { - return Ok(()); - }; - fs::set_permissions(output_path, fs::Permissions::from_mode(mode)).map_err(|err| { - format!( - "failed to set permissions on curated plugins file {}: {err}", - output_path.display() - ) - }) -} - -#[cfg(not(unix))] -fn apply_zip_permissions( - _entry: &zip::read::ZipFile<'_>, - _output_path: &Path, -) -> Result<(), String> { - Ok(()) -} - -#[cfg(test)] -#[path = "curated_repo_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/plugins/curated_repo_tests.rs b/codex-rs/core/src/plugins/curated_repo_tests.rs deleted file mode 100644 index 5a14124d06..0000000000 --- a/codex-rs/core/src/plugins/curated_repo_tests.rs +++ /dev/null @@ -1,159 +0,0 @@ -use super::*; -use pretty_assertions::assert_eq; -use std::io::Write; -use tempfile::tempdir; -use wiremock::Mock; -use wiremock::MockServer; -use wiremock::ResponseTemplate; -use wiremock::matchers::method; -use wiremock::matchers::path; -use zip::ZipWriter; -use zip::write::SimpleFileOptions; - -#[test] -fn curated_plugins_repo_path_uses_codex_home_tmp_dir() { - let tmp = tempdir().expect("tempdir"); - assert_eq!( - curated_plugins_repo_path(tmp.path()), - tmp.path().join(".tmp/plugins") - ); -} - -#[test] -fn read_curated_plugins_sha_reads_trimmed_sha_file() { - let tmp = tempdir().expect("tempdir"); - fs::create_dir_all(tmp.path().join(".tmp")).expect("create tmp"); - fs::write(tmp.path().join(".tmp/plugins.sha"), "abc123\n").expect("write sha"); - - assert_eq!( - read_curated_plugins_sha(tmp.path()).as_deref(), - Some("abc123") - ); -} - -#[tokio::test] -async fn sync_openai_plugins_repo_downloads_zipball_and_records_sha() { - let tmp = tempdir().expect("tempdir"); - let server = MockServer::start().await; - let sha = "0123456789abcdef0123456789abcdef01234567"; - - Mock::given(method("GET")) - .and(path("/repos/openai/plugins")) - .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"default_branch":"main"}"#)) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/repos/openai/plugins/git/ref/heads/main")) - .respond_with( - ResponseTemplate::new(200) - .set_body_string(format!(r#"{{"object":{{"sha":"{sha}"}}}}"#)), - ) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path(format!("/repos/openai/plugins/zipball/{sha}"))) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "application/zip") - .set_body_bytes(curated_repo_zipball_bytes(sha)), - ) - .mount(&server) - .await; - - let server_uri = server.uri(); - let tmp_path = tmp.path().to_path_buf(); - tokio::task::spawn_blocking(move || { - sync_openai_plugins_repo_with_api_base_url(tmp_path.as_path(), &server_uri) - }) - .await - .expect("sync task should join") - .expect("sync should succeed"); - - let repo_path = curated_plugins_repo_path(tmp.path()); - assert!(repo_path.join(".agents/plugins/marketplace.json").is_file()); - assert!( - repo_path - .join("plugins/gmail/.codex-plugin/plugin.json") - .is_file() - ); - assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha)); -} - -#[tokio::test] -async fn sync_openai_plugins_repo_skips_archive_download_when_sha_matches() { - let tmp = tempdir().expect("tempdir"); - let repo_path = curated_plugins_repo_path(tmp.path()); - fs::create_dir_all(repo_path.join(".agents/plugins")).expect("create repo"); - fs::write( - repo_path.join(".agents/plugins/marketplace.json"), - r#"{"name":"openai-curated","plugins":[]}"#, - ) - .expect("write marketplace"); - fs::create_dir_all(tmp.path().join(".tmp")).expect("create tmp"); - let sha = "fedcba9876543210fedcba9876543210fedcba98"; - fs::write(tmp.path().join(".tmp/plugins.sha"), format!("{sha}\n")).expect("write sha"); - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/repos/openai/plugins")) - .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"default_branch":"main"}"#)) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/repos/openai/plugins/git/ref/heads/main")) - .respond_with( - ResponseTemplate::new(200) - .set_body_string(format!(r#"{{"object":{{"sha":"{sha}"}}}}"#)), - ) - .mount(&server) - .await; - - let server_uri = server.uri(); - let tmp_path = tmp.path().to_path_buf(); - tokio::task::spawn_blocking(move || { - sync_openai_plugins_repo_with_api_base_url(tmp_path.as_path(), &server_uri) - }) - .await - .expect("sync task should join") - .expect("sync should succeed"); - - assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha)); - assert!(repo_path.join(".agents/plugins/marketplace.json").is_file()); -} - -fn curated_repo_zipball_bytes(sha: &str) -> Vec { - let cursor = Cursor::new(Vec::new()); - let mut writer = ZipWriter::new(cursor); - let options = SimpleFileOptions::default(); - let root = format!("openai-plugins-{sha}"); - writer - .start_file(format!("{root}/.agents/plugins/marketplace.json"), options) - .expect("start marketplace entry"); - writer - .write_all( - br#"{ - "name": "openai-curated", - "plugins": [ - { - "name": "gmail", - "source": { - "source": "local", - "path": "./plugins/gmail" - } - } - ] -}"#, - ) - .expect("write marketplace"); - writer - .start_file( - format!("{root}/plugins/gmail/.codex-plugin/plugin.json"), - options, - ) - .expect("start plugin manifest entry"); - writer - .write_all(br#"{"name":"gmail"}"#) - .expect("write plugin manifest"); - - writer.finish().expect("finish zip writer").into_inner() -} diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index 5498d762c2..f02758d273 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -60,6 +60,7 @@ use std::sync::RwLock; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::time::Instant; +use tokio::sync::Mutex; use toml_edit::value; use tracing::info; use tracing::warn; @@ -463,6 +464,7 @@ pub struct PluginsManager { store: PluginStore, featured_plugin_ids_cache: RwLock>, cached_enabled_outcome: RwLock>, + remote_sync_lock: Mutex<()>, restriction_product: Option, analytics_events_client: RwLock>, } @@ -488,6 +490,7 @@ impl PluginsManager { store: PluginStore::new(codex_home), featured_plugin_ids_cache: RwLock::new(None), cached_enabled_outcome: RwLock::new(None), + remote_sync_lock: Mutex::new(()), restriction_product, analytics_events_client: RwLock::new(None), } @@ -619,7 +622,8 @@ impl PluginsManager { if let Some(featured_plugin_ids) = self.cached_featured_plugin_ids(&cache_key) { return Ok(featured_plugin_ids); } - let featured_plugin_ids = fetch_remote_featured_plugin_ids(config, auth).await?; + let featured_plugin_ids = + fetch_remote_featured_plugin_ids(config, auth, self.restriction_product).await?; self.write_featured_plugin_ids_cache(cache_key, &featured_plugin_ids); Ok(featured_plugin_ids) } @@ -777,6 +781,8 @@ impl PluginsManager { auth: Option<&CodexAuth>, additive_only: bool, ) -> Result { + let _remote_sync_guard = self.remote_sync_lock.lock().await; + if !config.features.enabled(Feature::Plugins) { return Ok(RemotePluginSyncResult::default()); } diff --git a/codex-rs/core/src/plugins/manager_tests.rs b/codex-rs/core/src/plugins/manager_tests.rs index c443433803..cd8541a85c 100644 --- a/codex-rs/core/src/plugins/manager_tests.rs +++ b/codex-rs/core/src/plugins/manager_tests.rs @@ -23,6 +23,7 @@ use wiremock::ResponseTemplate; use wiremock::matchers::header; use wiremock::matchers::method; use wiremock::matchers::path; +use wiremock::matchers::query_param; fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) { let plugin_root = root.join(dir_name); @@ -1899,6 +1900,74 @@ plugins = true ); } +#[tokio::test] +async fn featured_plugin_ids_for_config_uses_restriction_product_query_param() { + let tmp = tempfile::tempdir().unwrap(); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +"#, + ); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/plugins/featured")) + .and(query_param("platform", "chat")) + .and(header("authorization", "Bearer Access Token")) + .and(header("chatgpt-account-id", "account_id")) + .respond_with(ResponseTemplate::new(200).set_body_string(r#"["chat-plugin"]"#)) + .mount(&server) + .await; + + let mut config = load_config(tmp.path(), tmp.path()).await; + config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); + let manager = PluginsManager::new_with_restriction_product( + tmp.path().to_path_buf(), + Some(Product::Chatgpt), + ); + + let featured_plugin_ids = manager + .featured_plugin_ids_for_config( + &config, + Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), + ) + .await + .unwrap(); + + assert_eq!(featured_plugin_ids, vec!["chat-plugin".to_string()]); +} + +#[tokio::test] +async fn featured_plugin_ids_for_config_defaults_query_param_to_codex() { + let tmp = tempfile::tempdir().unwrap(); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +"#, + ); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/plugins/featured")) + .and(query_param("platform", "codex")) + .respond_with(ResponseTemplate::new(200).set_body_string(r#"["codex-plugin"]"#)) + .mount(&server) + .await; + + let mut config = load_config(tmp.path(), tmp.path()).await; + config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); + let manager = PluginsManager::new_with_restriction_product(tmp.path().to_path_buf(), None); + + let featured_plugin_ids = manager + .featured_plugin_ids_for_config(&config, None) + .await + .unwrap(); + + assert_eq!(featured_plugin_ids, vec!["codex-plugin".to_string()]); +} + #[test] fn refresh_curated_plugin_cache_replaces_existing_local_version_with_sha() { let tmp = tempfile::tempdir().unwrap(); diff --git a/codex-rs/core/src/plugins/mod.rs b/codex-rs/core/src/plugins/mod.rs index ec338d1913..3e1e6db28d 100644 --- a/codex-rs/core/src/plugins/mod.rs +++ b/codex-rs/core/src/plugins/mod.rs @@ -1,4 +1,3 @@ -mod curated_repo; mod discoverable; mod injection; mod manager; @@ -12,9 +11,6 @@ mod store; pub(crate) mod test_support; mod toggles; -pub(crate) use curated_repo::curated_plugins_repo_path; -pub(crate) use curated_repo::read_curated_plugins_sha; -pub(crate) use curated_repo::sync_openai_plugins_repo; pub(crate) use discoverable::list_tool_suggest_discoverable_plugins; pub(crate) use injection::build_plugin_injections; pub use manager::AppConnectorId; @@ -52,5 +48,8 @@ pub use remote::RemotePluginFetchError; pub use remote::fetch_remote_featured_plugin_ids; pub(crate) use render::render_explicit_plugin_instructions; pub(crate) use render::render_plugins_section; +pub(crate) use startup_sync::curated_plugins_repo_path; +pub(crate) use startup_sync::read_curated_plugins_sha; +pub(crate) use startup_sync::sync_openai_plugins_repo; pub use store::PluginId; pub use toggles::collect_plugin_enabled_candidates; diff --git a/codex-rs/core/src/plugins/remote.rs b/codex-rs/core/src/plugins/remote.rs index 898767e35f..756824b339 100644 --- a/codex-rs/core/src/plugins/remote.rs +++ b/codex-rs/core/src/plugins/remote.rs @@ -1,6 +1,7 @@ use crate::auth::CodexAuth; use crate::config::Config; use crate::default_client::build_reqwest_client; +use codex_protocol::protocol::Product; use serde::Deserialize; use std::time::Duration; use url::Url; @@ -162,12 +163,17 @@ pub(crate) async fn fetch_remote_plugin_status( pub async fn fetch_remote_featured_plugin_ids( config: &Config, auth: Option<&CodexAuth>, + product: Option, ) -> Result, RemotePluginFetchError> { let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/plugins/featured"); let client = build_reqwest_client(); let mut request = client .get(&url) + .query(&[( + "platform", + product.unwrap_or(Product::Codex).to_app_platform(), + )]) .timeout(REMOTE_FEATURED_PLUGIN_FETCH_TIMEOUT); if let Some(auth) = auth.filter(|auth| auth.is_chatgpt_auth()) { diff --git a/codex-rs/core/src/plugins/startup_sync.rs b/codex-rs/core/src/plugins/startup_sync.rs index b63cfbb094..3511c10c58 100644 --- a/codex-rs/core/src/plugins/startup_sync.rs +++ b/codex-rs/core/src/plugins/startup_sync.rs @@ -1,19 +1,143 @@ +use crate::default_client::build_reqwest_client; use std::path::Path; use std::path::PathBuf; +use std::process::Command; +use std::process::Output; +use std::process::Stdio; use std::sync::Arc; use std::time::Duration; +use reqwest::Client; +use serde::Deserialize; use tracing::info; use tracing::warn; +use zip::ZipArchive; use crate::AuthManager; use crate::config::Config; use super::PluginsManager; +const GITHUB_API_BASE_URL: &str = "https://api.github.com"; +const GITHUB_API_ACCEPT_HEADER: &str = "application/vnd.github+json"; +const GITHUB_API_VERSION_HEADER: &str = "2022-11-28"; +const OPENAI_PLUGINS_OWNER: &str = "openai"; +const OPENAI_PLUGINS_REPO: &str = "plugins"; +const CURATED_PLUGINS_RELATIVE_DIR: &str = ".tmp/plugins"; +const CURATED_PLUGINS_SHA_FILE: &str = ".tmp/plugins.sha"; +const CURATED_PLUGINS_GIT_TIMEOUT: Duration = Duration::from_secs(30); +const CURATED_PLUGINS_HTTP_TIMEOUT: Duration = Duration::from_secs(30); const STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE: &str = ".tmp/app-server-remote-plugin-sync-v1"; const STARTUP_REMOTE_PLUGIN_SYNC_PREREQUISITE_TIMEOUT: Duration = Duration::from_secs(5); +#[derive(Debug, Deserialize)] +struct GitHubRepositorySummary { + default_branch: String, +} + +#[derive(Debug, Deserialize)] +struct GitHubGitRefSummary { + object: GitHubGitRefObject, +} + +#[derive(Debug, Deserialize)] +struct GitHubGitRefObject { + sha: String, +} + +pub(crate) fn curated_plugins_repo_path(codex_home: &Path) -> PathBuf { + codex_home.join(CURATED_PLUGINS_RELATIVE_DIR) +} + +pub(crate) fn read_curated_plugins_sha(codex_home: &Path) -> Option { + read_sha_file(codex_home.join(CURATED_PLUGINS_SHA_FILE).as_path()) +} + +pub(crate) fn sync_openai_plugins_repo(codex_home: &Path) -> Result { + sync_openai_plugins_repo_with_transport_overrides(codex_home, "git", GITHUB_API_BASE_URL) +} + +fn sync_openai_plugins_repo_with_transport_overrides( + codex_home: &Path, + git_binary: &str, + api_base_url: &str, +) -> Result { + match sync_openai_plugins_repo_via_git(codex_home, git_binary) { + Ok(remote_sha) => Ok(remote_sha), + Err(err) => { + warn!( + error = %err, + git_binary, + "git sync failed for curated plugin sync; falling back to GitHub HTTP" + ); + sync_openai_plugins_repo_via_http(codex_home, api_base_url) + } + } +} + +fn sync_openai_plugins_repo_via_git(codex_home: &Path, git_binary: &str) -> Result { + let repo_path = curated_plugins_repo_path(codex_home); + let sha_path = codex_home.join(CURATED_PLUGINS_SHA_FILE); + let remote_sha = git_ls_remote_head_sha(git_binary)?; + let local_sha = read_local_git_or_sha_file(&repo_path, &sha_path, git_binary); + + if local_sha.as_deref() == Some(remote_sha.as_str()) && repo_path.join(".git").is_dir() { + return Ok(remote_sha); + } + + let cloned_repo_path = prepare_curated_repo_parent_and_temp_dir(&repo_path)?; + let clone_output = run_git_command_with_timeout( + Command::new(git_binary) + .env("GIT_OPTIONAL_LOCKS", "0") + .arg("clone") + .arg("--depth") + .arg("1") + .arg("https://github.com/openai/plugins.git") + .arg(&cloned_repo_path), + "git clone curated plugins repo", + CURATED_PLUGINS_GIT_TIMEOUT, + )?; + ensure_git_success(&clone_output, "git clone curated plugins repo")?; + + let cloned_sha = git_head_sha(&cloned_repo_path, git_binary)?; + if cloned_sha != remote_sha { + return Err(format!( + "curated plugins clone HEAD mismatch: expected {remote_sha}, got {cloned_sha}" + )); + } + + ensure_marketplace_manifest_exists(&cloned_repo_path)?; + activate_curated_repo(&repo_path, &cloned_repo_path)?; + write_curated_plugins_sha(&sha_path, &remote_sha)?; + Ok(remote_sha) +} + +fn sync_openai_plugins_repo_via_http( + codex_home: &Path, + api_base_url: &str, +) -> Result { + let repo_path = curated_plugins_repo_path(codex_home); + let sha_path = codex_home.join(CURATED_PLUGINS_SHA_FILE); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|err| format!("failed to create curated plugins sync runtime: {err}"))?; + let remote_sha = runtime.block_on(fetch_curated_repo_remote_sha(api_base_url))?; + let local_sha = read_sha_file(&sha_path); + + if local_sha.as_deref() == Some(remote_sha.as_str()) && repo_path.is_dir() { + return Ok(remote_sha); + } + + let cloned_repo_path = prepare_curated_repo_parent_and_temp_dir(&repo_path)?; + let zipball_bytes = runtime.block_on(fetch_curated_repo_zipball(api_base_url, &remote_sha))?; + extract_zipball_to_dir(&zipball_bytes, &cloned_repo_path)?; + ensure_marketplace_manifest_exists(&cloned_repo_path)?; + activate_curated_repo(&repo_path, &cloned_repo_path)?; + write_curated_plugins_sha(&sha_path, &remote_sha)?; + Ok(remote_sha) +} + pub(super) fn start_startup_remote_plugin_sync_once( manager: Arc, codex_home: PathBuf, @@ -103,93 +227,438 @@ async fn write_startup_remote_plugin_sync_marker(codex_home: &Path) -> std::io:: tokio::fs::write(marker_path, b"ok\n").await } -#[cfg(test)] -mod tests { - use super::*; - use crate::auth::CodexAuth; - use crate::config::CONFIG_TOML_FILE; - use crate::plugins::curated_plugins_repo_path; - use crate::plugins::test_support::TEST_CURATED_PLUGIN_SHA; - use crate::plugins::test_support::write_curated_plugin_sha; - use crate::plugins::test_support::write_file; - use crate::plugins::test_support::write_openai_curated_marketplace; - use pretty_assertions::assert_eq; - use tempfile::tempdir; - use wiremock::Mock; - use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::header; - use wiremock::matchers::method; - use wiremock::matchers::path; +fn prepare_curated_repo_parent_and_temp_dir(repo_path: &Path) -> Result { + let Some(parent) = repo_path.parent() else { + return Err(format!( + "failed to determine curated plugins parent directory for {}", + repo_path.display() + )); + }; + std::fs::create_dir_all(parent).map_err(|err| { + format!( + "failed to create curated plugins parent directory {}: {err}", + parent.display() + ) + })?; - #[tokio::test] - async fn startup_remote_plugin_sync_writes_marker_and_reconciles_state() { - let tmp = tempdir().expect("tempdir"); - let curated_root = curated_plugins_repo_path(tmp.path()); - write_openai_curated_marketplace(&curated_root, &["linear"]); - write_curated_plugin_sha(tmp.path()); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = true + let clone_dir = tempfile::Builder::new() + .prefix("plugins-clone-") + .tempdir_in(parent) + .map_err(|err| { + format!( + "failed to create temporary curated plugins directory in {}: {err}", + parent.display() + ) + })?; + Ok(clone_dir.keep()) +} -[plugins."linear@openai-curated"] -enabled = false -"#, - ); +fn ensure_marketplace_manifest_exists(repo_path: &Path) -> Result<(), String> { + if repo_path.join(".agents/plugins/marketplace.json").is_file() { + return Ok(()); + } + Err(format!( + "curated plugins archive missing marketplace manifest at {}", + repo_path.join(".agents/plugins/marketplace.json").display() + )) +} - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/backend-api/plugins/list")) - .and(header("authorization", "Bearer Access Token")) - .and(header("chatgpt-account-id", "account_id")) - .respond_with(ResponseTemplate::new(200).set_body_string( - r#"[ - {"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} -]"#, - )) - .mount(&server) - .await; +fn activate_curated_repo(repo_path: &Path, staged_repo_path: &Path) -> Result<(), String> { + if repo_path.exists() { + let parent = repo_path.parent().ok_or_else(|| { + format!( + "failed to determine curated plugins parent directory for {}", + repo_path.display() + ) + })?; + let backup_dir = tempfile::Builder::new() + .prefix("plugins-backup-") + .tempdir_in(parent) + .map_err(|err| { + format!( + "failed to create curated plugins backup directory in {}: {err}", + parent.display() + ) + })?; + let backup_repo_path = backup_dir.path().join("repo"); - let mut config = crate::plugins::test_support::load_plugins_config(tmp.path()).await; - config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); - let manager = Arc::new(PluginsManager::new(tmp.path().to_path_buf())); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + std::fs::rename(repo_path, &backup_repo_path).map_err(|err| { + format!( + "failed to move previous curated plugins repo out of the way at {}: {err}", + repo_path.display() + ) + })?; - start_startup_remote_plugin_sync_once( - Arc::clone(&manager), - tmp.path().to_path_buf(), - config, - auth_manager, - ); - - let marker_path = tmp.path().join(STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE); - tokio::time::timeout(Duration::from_secs(5), async { - loop { - if marker_path.is_file() { - break; + if let Err(err) = std::fs::rename(staged_repo_path, repo_path) { + let rollback_result = std::fs::rename(&backup_repo_path, repo_path); + return match rollback_result { + Ok(()) => Err(format!( + "failed to activate new curated plugins repo at {}: {err}", + repo_path.display() + )), + Err(rollback_err) => { + let backup_path = backup_dir.keep().join("repo"); + Err(format!( + "failed to activate new curated plugins repo at {}: {err}; failed to restore previous repo (left at {}): {rollback_err}", + repo_path.display(), + backup_path.display() + )) } - tokio::time::sleep(Duration::from_millis(10)).await; + }; + } + } else { + std::fs::rename(staged_repo_path, repo_path).map_err(|err| { + format!( + "failed to activate curated plugins repo at {}: {err}", + repo_path.display() + ) + })?; + } + + Ok(()) +} + +fn write_curated_plugins_sha(sha_path: &Path, remote_sha: &str) -> Result<(), String> { + if let Some(parent) = sha_path.parent() { + std::fs::create_dir_all(parent).map_err(|err| { + format!( + "failed to create curated plugins sha directory {}: {err}", + parent.display() + ) + })?; + } + std::fs::write(sha_path, format!("{remote_sha}\n")).map_err(|err| { + format!( + "failed to write curated plugins sha file {}: {err}", + sha_path.display() + ) + }) +} + +fn read_local_git_or_sha_file( + repo_path: &Path, + sha_path: &Path, + git_binary: &str, +) -> Option { + if repo_path.join(".git").is_dir() + && let Ok(sha) = git_head_sha(repo_path, git_binary) + { + return Some(sha); + } + + read_sha_file(sha_path) +} + +fn git_ls_remote_head_sha(git_binary: &str) -> Result { + let output = run_git_command_with_timeout( + Command::new(git_binary) + .env("GIT_OPTIONAL_LOCKS", "0") + .arg("ls-remote") + .arg("https://github.com/openai/plugins.git") + .arg("HEAD"), + "git ls-remote curated plugins repo", + CURATED_PLUGINS_GIT_TIMEOUT, + )?; + ensure_git_success(&output, "git ls-remote curated plugins repo")?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let Some(first_line) = stdout.lines().next() else { + return Err("git ls-remote returned empty output for curated plugins repo".to_string()); + }; + let Some((sha, _)) = first_line.split_once('\t') else { + return Err(format!( + "unexpected git ls-remote output for curated plugins repo: {first_line}" + )); + }; + if sha.is_empty() { + return Err("git ls-remote returned empty sha for curated plugins repo".to_string()); + } + Ok(sha.to_string()) +} + +fn git_head_sha(repo_path: &Path, git_binary: &str) -> Result { + let output = Command::new(git_binary) + .env("GIT_OPTIONAL_LOCKS", "0") + .arg("-C") + .arg(repo_path) + .arg("rev-parse") + .arg("HEAD") + .output() + .map_err(|err| { + format!( + "failed to run git rev-parse HEAD in {}: {err}", + repo_path.display() + ) + })?; + ensure_git_success(&output, "git rev-parse HEAD")?; + + let sha = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if sha.is_empty() { + return Err(format!( + "git rev-parse HEAD returned empty output in {}", + repo_path.display() + )); + } + Ok(sha) +} + +fn run_git_command_with_timeout( + command: &mut Command, + context: &str, + timeout: Duration, +) -> Result { + let mut child = command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|err| format!("failed to run {context}: {err}"))?; + + let start = std::time::Instant::now(); + loop { + match child.try_wait() { + Ok(Some(_)) => { + return child + .wait_with_output() + .map_err(|err| format!("failed to wait for {context}: {err}")); } - }) - .await - .expect("marker should be written"); + Ok(None) => {} + Err(err) => return Err(format!("failed to poll {context}: {err}")), + } - assert!( - tmp.path() - .join(format!( - "plugins/cache/openai-curated/linear/{TEST_CURATED_PLUGIN_SHA}" + if start.elapsed() >= timeout { + match child.try_wait() { + Ok(Some(_)) => { + return child + .wait_with_output() + .map_err(|err| format!("failed to wait for {context}: {err}")); + } + Ok(None) => {} + Err(err) => return Err(format!("failed to poll {context}: {err}")), + } + + let _ = child.kill(); + let output = child + .wait_with_output() + .map_err(|err| format!("failed to wait for {context} after timeout: {err}"))?; + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return if stderr.is_empty() { + Err(format!("{context} timed out after {}s", timeout.as_secs())) + } else { + Err(format!( + "{context} timed out after {}s: {stderr}", + timeout.as_secs() )) - .is_dir() - ); - let config = std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)) - .expect("config should exist"); - assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); - assert!(config.contains("enabled = true")); + }; + } - let marker_contents = - std::fs::read_to_string(marker_path).expect("marker should be readable"); - assert_eq!(marker_contents, "ok\n"); + std::thread::sleep(Duration::from_millis(100)); } } + +fn ensure_git_success(output: &Output, context: &str) -> Result<(), String> { + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if stderr.is_empty() { + Err(format!("{context} failed with status {}", output.status)) + } else { + Err(format!( + "{context} failed with status {}: {stderr}", + output.status + )) + } +} + +async fn fetch_curated_repo_remote_sha(api_base_url: &str) -> Result { + let api_base_url = api_base_url.trim_end_matches('/'); + let repo_url = format!("{api_base_url}/repos/{OPENAI_PLUGINS_OWNER}/{OPENAI_PLUGINS_REPO}"); + let client = build_reqwest_client(); + let repo_body = fetch_github_text(&client, &repo_url, "get curated plugins repository").await?; + let repo_summary: GitHubRepositorySummary = + serde_json::from_str(&repo_body).map_err(|err| { + format!("failed to parse curated plugins repository response from {repo_url}: {err}") + })?; + if repo_summary.default_branch.is_empty() { + return Err(format!( + "curated plugins repository response from {repo_url} did not include a default branch" + )); + } + + let git_ref_url = format!("{repo_url}/git/ref/heads/{}", repo_summary.default_branch); + let git_ref_body = + fetch_github_text(&client, &git_ref_url, "get curated plugins HEAD ref").await?; + let git_ref: GitHubGitRefSummary = serde_json::from_str(&git_ref_body).map_err(|err| { + format!("failed to parse curated plugins ref response from {git_ref_url}: {err}") + })?; + if git_ref.object.sha.is_empty() { + return Err(format!( + "curated plugins ref response from {git_ref_url} did not include a HEAD sha" + )); + } + + Ok(git_ref.object.sha) +} + +async fn fetch_curated_repo_zipball( + api_base_url: &str, + remote_sha: &str, +) -> Result, String> { + let api_base_url = api_base_url.trim_end_matches('/'); + let repo_url = format!("{api_base_url}/repos/{OPENAI_PLUGINS_OWNER}/{OPENAI_PLUGINS_REPO}"); + let zipball_url = format!("{repo_url}/zipball/{remote_sha}"); + let client = build_reqwest_client(); + fetch_github_bytes(&client, &zipball_url, "download curated plugins archive").await +} + +async fn fetch_github_text(client: &Client, url: &str, context: &str) -> Result { + let response = github_request(client, url) + .send() + .await + .map_err(|err| format!("failed to {context} from {url}: {err}"))?; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(format!( + "{context} from {url} failed with status {status}: {body}" + )); + } + Ok(body) +} + +async fn fetch_github_bytes(client: &Client, url: &str, context: &str) -> Result, String> { + let response = github_request(client, url) + .send() + .await + .map_err(|err| format!("failed to {context} from {url}: {err}"))?; + let status = response.status(); + let body = response + .bytes() + .await + .map_err(|err| format!("failed to read {context} response from {url}: {err}"))?; + if !status.is_success() { + let body_text = String::from_utf8_lossy(&body); + return Err(format!( + "{context} from {url} failed with status {status}: {body_text}" + )); + } + Ok(body.to_vec()) +} + +fn github_request(client: &Client, url: &str) -> reqwest::RequestBuilder { + client + .get(url) + .timeout(CURATED_PLUGINS_HTTP_TIMEOUT) + .header("accept", GITHUB_API_ACCEPT_HEADER) + .header("x-github-api-version", GITHUB_API_VERSION_HEADER) +} + +fn read_sha_file(sha_path: &Path) -> Option { + std::fs::read_to_string(sha_path) + .ok() + .map(|sha| sha.trim().to_string()) + .filter(|sha| !sha.is_empty()) +} + +fn extract_zipball_to_dir(bytes: &[u8], destination: &Path) -> Result<(), String> { + std::fs::create_dir_all(destination).map_err(|err| { + format!( + "failed to create curated plugins extraction directory {}: {err}", + destination.display() + ) + })?; + + let cursor = std::io::Cursor::new(bytes); + let mut archive = ZipArchive::new(cursor) + .map_err(|err| format!("failed to open curated plugins zip archive: {err}"))?; + + for index in 0..archive.len() { + let mut entry = archive + .by_index(index) + .map_err(|err| format!("failed to read curated plugins zip entry: {err}"))?; + let Some(relative_path) = entry.enclosed_name() else { + return Err(format!( + "curated plugins zip entry `{}` escapes extraction root", + entry.name() + )); + }; + + let mut components = relative_path.components(); + let Some(std::path::Component::Normal(_)) = components.next() else { + continue; + }; + + let output_relative = components.fold(PathBuf::new(), |mut path, component| { + if let std::path::Component::Normal(segment) = component { + path.push(segment); + } + path + }); + if output_relative.as_os_str().is_empty() { + continue; + } + + let output_path = destination.join(&output_relative); + if entry.is_dir() { + std::fs::create_dir_all(&output_path).map_err(|err| { + format!( + "failed to create curated plugins directory {}: {err}", + output_path.display() + ) + })?; + continue; + } + + if let Some(parent) = output_path.parent() { + std::fs::create_dir_all(parent).map_err(|err| { + format!( + "failed to create curated plugins directory {}: {err}", + parent.display() + ) + })?; + } + let mut output = std::fs::File::create(&output_path).map_err(|err| { + format!( + "failed to create curated plugins file {}: {err}", + output_path.display() + ) + })?; + std::io::copy(&mut entry, &mut output).map_err(|err| { + format!( + "failed to write curated plugins file {}: {err}", + output_path.display() + ) + })?; + apply_zip_permissions(&entry, &output_path)?; + } + + Ok(()) +} + +#[cfg(unix)] +fn apply_zip_permissions(entry: &zip::read::ZipFile<'_>, output_path: &Path) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + + let Some(mode) = entry.unix_mode() else { + return Ok(()); + }; + std::fs::set_permissions(output_path, std::fs::Permissions::from_mode(mode)).map_err(|err| { + format!( + "failed to set permissions on curated plugins file {}: {err}", + output_path.display() + ) + }) +} + +#[cfg(not(unix))] +fn apply_zip_permissions( + _entry: &zip::read::ZipFile<'_>, + _output_path: &Path, +) -> Result<(), String> { + Ok(()) +} + +#[cfg(test)] +#[path = "startup_sync_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/plugins/startup_sync_tests.rs b/codex-rs/core/src/plugins/startup_sync_tests.rs new file mode 100644 index 0000000000..66c02c38f0 --- /dev/null +++ b/codex-rs/core/src/plugins/startup_sync_tests.rs @@ -0,0 +1,383 @@ +use super::*; +use crate::auth::CodexAuth; +use crate::config::CONFIG_TOML_FILE; +use crate::plugins::test_support::TEST_CURATED_PLUGIN_SHA; +use crate::plugins::test_support::write_curated_plugin_sha; +use crate::plugins::test_support::write_file; +use crate::plugins::test_support::write_openai_curated_marketplace; +use pretty_assertions::assert_eq; +use std::io::Write; +use tempfile::tempdir; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; +use zip::ZipWriter; +use zip::write::SimpleFileOptions; + +#[test] +fn curated_plugins_repo_path_uses_codex_home_tmp_dir() { + let tmp = tempdir().expect("tempdir"); + assert_eq!( + curated_plugins_repo_path(tmp.path()), + tmp.path().join(".tmp/plugins") + ); +} + +#[test] +fn read_curated_plugins_sha_reads_trimmed_sha_file() { + let tmp = tempdir().expect("tempdir"); + std::fs::create_dir_all(tmp.path().join(".tmp")).expect("create tmp"); + std::fs::write(tmp.path().join(".tmp/plugins.sha"), "abc123\n").expect("write sha"); + + assert_eq!( + read_curated_plugins_sha(tmp.path()).as_deref(), + Some("abc123") + ); +} + +#[cfg(unix)] +#[test] +fn sync_openai_plugins_repo_prefers_git_when_available() { + use std::os::unix::fs::PermissionsExt; + + let tmp = tempdir().expect("tempdir"); + let bin_dir = tempfile::Builder::new() + .prefix("fake-git-") + .tempdir() + .expect("tempdir"); + let git_path = bin_dir.path().join("git"); + let sha = "0123456789abcdef0123456789abcdef01234567"; + + std::fs::write( + &git_path, + format!( + r#"#!/bin/sh +if [ "$1" = "ls-remote" ]; then + printf '%s\tHEAD\n' "{sha}" + exit 0 +fi +if [ "$1" = "clone" ]; then + dest="$5" + mkdir -p "$dest/.git" "$dest/.agents/plugins" "$dest/plugins/gmail/.codex-plugin" + cat > "$dest/.agents/plugins/marketplace.json" <<'EOF' +{{"name":"openai-curated","plugins":[{{"name":"gmail","source":{{"source":"local","path":"./plugins/gmail"}}}}]}} +EOF + printf '%s\n' '{{"name":"gmail"}}' > "$dest/plugins/gmail/.codex-plugin/plugin.json" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "rev-parse" ] && [ "$4" = "HEAD" ]; then + printf '%s\n' "{sha}" + exit 0 +fi +echo "unexpected git invocation: $@" >&2 +exit 1 +"# + ), + ) + .expect("write fake git"); + let mut permissions = std::fs::metadata(&git_path) + .expect("metadata") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&git_path, permissions).expect("chmod"); + + let synced_sha = sync_openai_plugins_repo_with_transport_overrides( + tmp.path(), + git_path.to_str().expect("utf8 path"), + "http://127.0.0.1:9", + ) + .expect("git sync should succeed"); + + assert_eq!(synced_sha, sha); + assert!(curated_plugins_repo_path(tmp.path()).join(".git").is_dir()); + assert!( + curated_plugins_repo_path(tmp.path()) + .join(".agents/plugins/marketplace.json") + .is_file() + ); + assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha)); +} + +#[tokio::test] +async fn sync_openai_plugins_repo_falls_back_to_http_when_git_is_unavailable() { + let tmp = tempdir().expect("tempdir"); + let server = MockServer::start().await; + let sha = "0123456789abcdef0123456789abcdef01234567"; + + Mock::given(method("GET")) + .and(path("/repos/openai/plugins")) + .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"default_branch":"main"}"#)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/repos/openai/plugins/git/ref/heads/main")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(format!(r#"{{"object":{{"sha":"{sha}"}}}}"#)), + ) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path(format!("/repos/openai/plugins/zipball/{sha}"))) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "application/zip") + .set_body_bytes(curated_repo_zipball_bytes(sha)), + ) + .mount(&server) + .await; + + let server_uri = server.uri(); + let tmp_path = tmp.path().to_path_buf(); + let synced_sha = tokio::task::spawn_blocking(move || { + sync_openai_plugins_repo_with_transport_overrides( + tmp_path.as_path(), + "missing-git-for-test", + &server_uri, + ) + }) + .await + .expect("sync task should join") + .expect("fallback sync should succeed"); + + let repo_path = curated_plugins_repo_path(tmp.path()); + assert_eq!(synced_sha, sha); + assert!(repo_path.join(".agents/plugins/marketplace.json").is_file()); + assert!( + repo_path + .join("plugins/gmail/.codex-plugin/plugin.json") + .is_file() + ); + assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha)); +} + +#[cfg(unix)] +#[tokio::test] +async fn sync_openai_plugins_repo_falls_back_to_http_when_git_sync_fails() { + use std::os::unix::fs::PermissionsExt; + + let tmp = tempdir().expect("tempdir"); + let bin_dir = tempfile::Builder::new() + .prefix("fake-git-fail-") + .tempdir() + .expect("tempdir"); + let git_path = bin_dir.path().join("git"); + let sha = "0123456789abcdef0123456789abcdef01234567"; + + std::fs::write( + &git_path, + r#"#!/bin/sh +echo "simulated git failure" >&2 +exit 1 +"#, + ) + .expect("write fake git"); + let mut permissions = std::fs::metadata(&git_path) + .expect("metadata") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&git_path, permissions).expect("chmod"); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/openai/plugins")) + .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"default_branch":"main"}"#)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/repos/openai/plugins/git/ref/heads/main")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(format!(r#"{{"object":{{"sha":"{sha}"}}}}"#)), + ) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path(format!("/repos/openai/plugins/zipball/{sha}"))) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "application/zip") + .set_body_bytes(curated_repo_zipball_bytes(sha)), + ) + .mount(&server) + .await; + + let server_uri = server.uri(); + let tmp_path = tmp.path().to_path_buf(); + let synced_sha = tokio::task::spawn_blocking(move || { + sync_openai_plugins_repo_with_transport_overrides( + tmp_path.as_path(), + git_path.to_str().expect("utf8 path"), + &server_uri, + ) + }) + .await + .expect("sync task should join") + .expect("fallback sync should succeed"); + + let repo_path = curated_plugins_repo_path(tmp.path()); + assert_eq!(synced_sha, sha); + assert!(repo_path.join(".agents/plugins/marketplace.json").is_file()); + assert!( + repo_path + .join("plugins/gmail/.codex-plugin/plugin.json") + .is_file() + ); + assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha)); +} + +#[tokio::test] +async fn sync_openai_plugins_repo_skips_archive_download_when_sha_matches() { + let tmp = tempdir().expect("tempdir"); + let repo_path = curated_plugins_repo_path(tmp.path()); + std::fs::create_dir_all(repo_path.join(".agents/plugins")).expect("create repo"); + std::fs::write( + repo_path.join(".agents/plugins/marketplace.json"), + r#"{"name":"openai-curated","plugins":[]}"#, + ) + .expect("write marketplace"); + std::fs::create_dir_all(tmp.path().join(".tmp")).expect("create tmp"); + let sha = "fedcba9876543210fedcba9876543210fedcba98"; + std::fs::write(tmp.path().join(".tmp/plugins.sha"), format!("{sha}\n")).expect("write sha"); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/openai/plugins")) + .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"default_branch":"main"}"#)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/repos/openai/plugins/git/ref/heads/main")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(format!(r#"{{"object":{{"sha":"{sha}"}}}}"#)), + ) + .mount(&server) + .await; + + let server_uri = server.uri(); + let tmp_path = tmp.path().to_path_buf(); + tokio::task::spawn_blocking(move || { + sync_openai_plugins_repo_with_transport_overrides( + tmp_path.as_path(), + "missing-git-for-test", + &server_uri, + ) + }) + .await + .expect("sync task should join") + .expect("sync should succeed"); + + assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha)); + assert!(repo_path.join(".agents/plugins/marketplace.json").is_file()); +} + +#[tokio::test] +async fn startup_remote_plugin_sync_writes_marker_and_reconciles_state() { + let tmp = tempdir().expect("tempdir"); + let curated_root = curated_plugins_repo_path(tmp.path()); + write_openai_curated_marketplace(&curated_root, &["linear"]); + write_curated_plugin_sha(tmp.path()); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."linear@openai-curated"] +enabled = false +"#, + ); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/plugins/list")) + .and(header("authorization", "Bearer Access Token")) + .and(header("chatgpt-account-id", "account_id")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"[ + {"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} +]"#, + )) + .mount(&server) + .await; + + let mut config = crate::plugins::test_support::load_plugins_config(tmp.path()).await; + config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); + let manager = Arc::new(PluginsManager::new(tmp.path().to_path_buf())); + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + + start_startup_remote_plugin_sync_once( + Arc::clone(&manager), + tmp.path().to_path_buf(), + config, + auth_manager, + ); + + let marker_path = tmp.path().join(STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if marker_path.is_file() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("marker should be written"); + + assert!( + tmp.path() + .join(format!( + "plugins/cache/openai-curated/linear/{TEST_CURATED_PLUGIN_SHA}" + )) + .is_dir() + ); + let config = + std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("config should exist"); + assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); + assert!(config.contains("enabled = true")); + + let marker_contents = std::fs::read_to_string(marker_path).expect("marker should be readable"); + assert_eq!(marker_contents, "ok\n"); +} + +fn curated_repo_zipball_bytes(sha: &str) -> Vec { + let cursor = std::io::Cursor::new(Vec::new()); + let mut writer = ZipWriter::new(cursor); + let options = SimpleFileOptions::default(); + let root = format!("openai-plugins-{sha}"); + writer + .start_file(format!("{root}/.agents/plugins/marketplace.json"), options) + .expect("start marketplace entry"); + writer + .write_all( + br#"{ + "name": "openai-curated", + "plugins": [ + { + "name": "gmail", + "source": { + "source": "local", + "path": "./plugins/gmail" + } + } + ] +}"#, + ) + .expect("write marketplace"); + writer + .start_file( + format!("{root}/plugins/gmail/.codex-plugin/plugin.json"), + options, + ) + .expect("start plugin manifest entry"); + writer + .write_all(br#"{"name":"gmail"}"#) + .expect("write plugin manifest"); + + writer.finish().expect("finish zip writer").into_inner() +} diff --git a/codex-rs/core/src/realtime_context_tests.rs b/codex-rs/core/src/realtime_context_tests.rs index a04b771396..a19abf3edd 100644 --- a/codex-rs/core/src/realtime_context_tests.rs +++ b/codex-rs/core/src/realtime_context_tests.rs @@ -23,6 +23,7 @@ fn thread_metadata(cwd: &str, title: &str, first_user_message: &str) -> ThreadMe .single() .expect("valid timestamp"), source: "cli".to_string(), + agent_path: None, agent_nickname: None, agent_role: None, model_provider: "test-provider".to_string(), diff --git a/codex-rs/core/src/rollout/metadata.rs b/codex-rs/core/src/rollout/metadata.rs index d2edfbb0d8..5b032d217d 100644 --- a/codex-rs/core/src/rollout/metadata.rs +++ b/codex-rs/core/src/rollout/metadata.rs @@ -49,6 +49,7 @@ pub(crate) fn builder_from_session_meta( builder.model_provider = session_meta.meta.model_provider.clone(); builder.agent_nickname = session_meta.meta.agent_nickname.clone(); builder.agent_role = session_meta.meta.agent_role.clone(); + builder.agent_path = session_meta.meta.agent_path.clone(); builder.cwd = session_meta.meta.cwd.clone(); builder.cli_version = Some(session_meta.meta.cli_version.clone()); builder.sandbox_policy = SandboxPolicy::new_read_only_policy(); diff --git a/codex-rs/core/src/rollout/metadata_tests.rs b/codex-rs/core/src/rollout/metadata_tests.rs index 5556d7002d..dacd9e67b8 100644 --- a/codex-rs/core/src/rollout/metadata_tests.rs +++ b/codex-rs/core/src/rollout/metadata_tests.rs @@ -38,6 +38,7 @@ async fn extract_metadata_from_rollout_uses_session_meta() { originator: "cli".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::default(), + agent_path: None, agent_nickname: None, agent_role: None, model_provider: Some("openai".to_string()), @@ -88,6 +89,7 @@ async fn extract_metadata_from_rollout_returns_latest_memory_mode() { originator: "cli".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::default(), + agent_path: None, agent_nickname: None, agent_role: None, model_provider: Some("openai".to_string()), @@ -355,6 +357,7 @@ fn write_rollout_in_sessions_with_cwd( originator: "cli".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::default(), + agent_path: None, agent_nickname: None, agent_role: None, model_provider: Some("test-provider".to_string()), diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index 002269d59e..72a3e3c637 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -405,6 +405,7 @@ impl RolloutRecorder { cli_version: env!("CARGO_PKG_VERSION").to_string(), agent_nickname: source.get_nickname(), agent_role: source.get_agent_role(), + agent_path: source.get_agent_path().map(Into::into), source, model_provider: Some(config.model_provider_id.clone()), base_instructions: Some(base_instructions), diff --git a/codex-rs/core/src/rollout/tests.rs b/codex-rs/core/src/rollout/tests.rs index 688df69863..79f0c5a8a6 100644 --- a/codex-rs/core/src/rollout/tests.rs +++ b/codex-rs/core/src/rollout/tests.rs @@ -1101,6 +1101,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { originator: "test_originator".into(), cli_version: "test_version".into(), source: SessionSource::VSCode, + agent_path: None, agent_nickname: None, agent_role: None, model_provider: Some("test-provider".into()), diff --git a/codex-rs/core/src/session_prefix.rs b/codex-rs/core/src/session_prefix.rs index db3ac00a6d..42f213a1d3 100644 --- a/codex-rs/core/src/session_prefix.rs +++ b/codex-rs/core/src/session_prefix.rs @@ -4,18 +4,25 @@ use codex_protocol::protocol::AgentStatus; /// messages but are not user intent. use crate::contextual_user_message::SUBAGENT_NOTIFICATION_FRAGMENT; -pub(crate) fn format_subagent_notification_message(agent_id: &str, status: &AgentStatus) -> String { +// TODO(jif) unify with structured schema +pub(crate) fn format_subagent_notification_message( + agent_reference: &str, + status: &AgentStatus, +) -> String { let payload_json = serde_json::json!({ - "agent_id": agent_id, + "agent_path": agent_reference, "status": status, }) .to_string(); SUBAGENT_NOTIFICATION_FRAGMENT.wrap(payload_json) } -pub(crate) fn format_subagent_context_line(agent_id: &str, agent_nickname: Option<&str>) -> String { +pub(crate) fn format_subagent_context_line( + agent_reference: &str, + agent_nickname: Option<&str>, +) -> String { match agent_nickname.filter(|nickname| !nickname.is_empty()) { - Some(agent_nickname) => format!("- {agent_id}: {agent_nickname}"), - None => format!("- {agent_id}"), + Some(agent_nickname) => format!("- {agent_reference}: {agent_nickname}"), + None => format!("- {agent_reference}"), } } diff --git a/codex-rs/core/src/snapshots/codex_core__codex_tests__fork_startup_context_then_first_turn_diff.snap b/codex-rs/core/src/snapshots/codex_core__codex_tests__fork_startup_context_then_first_turn_diff.snap new file mode 100644 index 0000000000..90bb82d409 --- /dev/null +++ b/codex-rs/core/src/snapshots/codex_core__codex_tests__fork_startup_context_then_first_turn_diff.snap @@ -0,0 +1,17 @@ +--- +source: core/src/codex_tests.rs +assertion_line: 1282 +expression: snapshot +--- +Scenario: First request after fork when fork startup changes approval policy and the first forked turn changes approval policy again and enters plan mode. + +## First Forked Turn Request +00:message/developer: +01:message/user:> +02:message/user:fork seed +03:message/developer: +04:message/user:> +05:message/developer[2]: + [01] + [02] Fork turn collaboration instructions. +06:message/user:after fork diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index c325681e93..dab41a9e9d 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -153,7 +153,15 @@ impl Session { ) { self.abort_all_tasks(TurnAbortReason::Replaced).await; self.clear_connector_selection().await; + self.start_task(turn_context, input, task).await; + } + async fn start_task( + self: &Arc, + turn_context: Arc, + input: Vec, + task: T, + ) { let task: Arc = Arc::new(task); let task_kind = task.kind(); let span_name = task.span_name(); @@ -224,6 +232,22 @@ impl Session { .await; } + pub(crate) async fn ensure_task_for_queued_response_items(self: &Arc) { + if !self.has_queued_response_items_for_next_turn().await { + return; + } + + if self.active_turn.lock().await.is_some() { + return; + } + + let turn_context = self.new_default_turn().await; + self.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref()) + .await; + self.start_task(turn_context, Vec::new(), RegularTask::new()) + .await; + } + pub async fn abort_all_tasks(self: &Arc, reason: TurnAbortReason) { if let Some(mut active_turn) = self.take_active_turn().await { for task in active_turn.drain_tasks() { @@ -233,6 +257,9 @@ impl Session { // in-flight approval wait can surface as a model-visible rejection before TurnAborted. active_turn.clear_pending().await; } + if reason == TurnAbortReason::Interrupted { + self.ensure_task_for_queued_response_items().await; + } } pub async fn on_task_finished( @@ -371,6 +398,9 @@ impl Session { let mut turn = ActiveTurn::default(); let mut turn_state = turn.turn_state.lock().await; turn_state.token_usage_at_turn_start = token_usage_at_turn_start; + for item in self.take_queued_response_items_for_next_turn().await { + turn_state.push_pending_input(item); + } drop(turn_state); turn.add_task(task); *active = Some(turn); diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index a63cf2cb94..d93bf37187 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -3,6 +3,8 @@ use crate::CodexAuth; use crate::ModelProviderInfo; use crate::OPENAI_PROVIDER_ID; use crate::agent::AgentControl; +use crate::agent::inter_agent_instruction::InterAgentDelivery; +use crate::agent::inter_agent_instruction::InterAgentInstruction; use crate::codex::Codex; use crate::codex::CodexSpawnArgs; use crate::codex::CodexSpawnOk; @@ -26,6 +28,8 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills::SkillsManager; use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationModeMask; +#[cfg(test)] +use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelPreset; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::McpServerRefreshConfig; @@ -606,6 +610,29 @@ impl ThreadManagerState { thread.submit(op).await } + #[cfg(test)] + /// Append a prebuilt message to a thread by ID outside the normal user-input path. + pub(crate) async fn append_message( + &self, + thread_id: ThreadId, + message: ResponseItem, + ) -> CodexResult { + let thread = self.get_thread(thread_id).await?; + thread.append_message(message).await + } + + pub(crate) async fn deliver_inter_agent_instruction( + &self, + thread_id: ThreadId, + instruction: InterAgentInstruction, + delivery: InterAgentDelivery, + ) -> CodexResult { + let thread = self.get_thread(thread_id).await?; + thread + .deliver_inter_agent_instruction(instruction, delivery) + .await + } + /// Remove a thread from the manager by ID, returning it when present. pub(crate) async fn remove_thread(&self, thread_id: &ThreadId) -> Option> { self.threads.write().await.remove(thread_id) diff --git a/codex-rs/core/src/tools/code_mode/bridge.js b/codex-rs/core/src/tools/code_mode/bridge.js deleted file mode 100644 index 0c61a9db19..0000000000 --- a/codex-rs/core/src/tools/code_mode/bridge.js +++ /dev/null @@ -1,51 +0,0 @@ -const __codexContentItems = Array.isArray(globalThis.__codexContentItems) - ? globalThis.__codexContentItems - : []; -const __codexRuntime = globalThis.__codexRuntime; - -delete globalThis.__codexRuntime; - -Object.defineProperty(globalThis, '__codexContentItems', { - value: __codexContentItems, - configurable: true, - enumerable: false, - writable: false, -}); - -(() => { - if (!__codexRuntime || typeof __codexRuntime !== 'object') { - throw new Error('code mode runtime is unavailable'); - } - - function defineGlobal(name, value) { - Object.defineProperty(globalThis, name, { - value, - configurable: true, - enumerable: true, - writable: false, - }); - } - - defineGlobal('ALL_TOOLS', __codexRuntime.ALL_TOOLS); - defineGlobal('exit', __codexRuntime.exit); - defineGlobal('image', __codexRuntime.image); - defineGlobal('load', __codexRuntime.load); - defineGlobal('notify', __codexRuntime.notify); - defineGlobal('store', __codexRuntime.store); - defineGlobal('text', __codexRuntime.text); - defineGlobal('tools', __codexRuntime.tools); - defineGlobal('yield_control', __codexRuntime.yield_control); - - defineGlobal( - 'console', - Object.freeze({ - log() {}, - info() {}, - warn() {}, - error() {}, - debug() {}, - }) - ); -})(); - -__CODE_MODE_USER_CODE_PLACEHOLDER__ diff --git a/codex-rs/core/src/tools/code_mode/description.md b/codex-rs/core/src/tools/code_mode/description.md deleted file mode 100644 index e0a124c65f..0000000000 --- a/codex-rs/core/src/tools/code_mode/description.md +++ /dev/null @@ -1,19 +0,0 @@ -## exec -- Runs raw JavaScript in an isolated context (no Node, no file system, or network access, no console). -- Send raw JavaScript source text, not JSON, quoted strings, or markdown code fences. -- You may optionally start the tool input with a first-line pragma like `// @exec: {"yield_time_ms": 10000, "max_output_tokens": 1000}`. -- `yield_time_ms` asks `exec` to yield early after that many milliseconds if the script is still running. -- `max_output_tokens` sets the token budget for direct `exec` results. By default the result is truncated to 10000 tokens. -- All nested tools are available on the global `tools` object, for example `await tools.exec_command(...)`. Tool names are exposed as normalized JavaScript identifiers, for example `await tools.mcp__ologs__get_profile(...)`. -- Tool methods take either string or object as parameter. -- They return either a structured value or a string based on the description above. - -- Global helpers: -- `exit()`: Immediately ends the current script successfully (like an early return from the top level). -- `text(value: string | number | boolean | undefined | null)`: Appends a text item and returns it. Non-string values are stringified with `JSON.stringify(...)` when possible. -- `image(imageUrlOrItem: string | { image_url: string; detail?: "auto" | "low" | "high" | "original" | null })`: Appends an image item and returns it. `image_url` can be an HTTPS URL or a base64-encoded `data:` URL. -- `store(key: string, value: any)`: stores a serializable value under a string key for later `exec` calls in the same session. -- `load(key: string)`: returns the stored value for a string key, or `undefined` if it is missing. -- `notify(value: string | number | boolean | undefined | null)`: immediately injects an extra `custom_tool_call_output` for the current `exec` call. Values are stringified like `text(...)`. -- `ALL_TOOLS`: metadata for the enabled nested tools as `{ name, description }` entries. -- `yield_control()`: yields the accumulated output to the model immediately while the script keeps running. diff --git a/codex-rs/core/src/tools/code_mode/execute_handler.rs b/codex-rs/core/src/tools/code_mode/execute_handler.rs index 9eba126dd1..3f77216c16 100644 --- a/codex-rs/core/src/tools/code_mode/execute_handler.rs +++ b/codex-rs/core/src/tools/code_mode/execute_handler.rs @@ -1,8 +1,5 @@ use async_trait::async_trait; -use serde::Deserialize; -use crate::codex::Session; -use crate::codex::TurnContext; use crate::function_tool::FunctionCallError; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; @@ -10,180 +7,52 @@ use crate::tools::context::ToolPayload; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; -use super::CODE_MODE_PRAGMA_PREFIX; -use super::CodeModeSessionProgress; use super::ExecContext; use super::PUBLIC_TOOL_NAME; use super::build_enabled_tools; -use super::handle_node_message; -use super::protocol::HostToNodeMessage; -use super::protocol::build_source; +use super::handle_runtime_response; pub struct CodeModeExecuteHandler; -const MAX_JS_SAFE_INTEGER: u64 = (1_u64 << 53) - 1; - -#[derive(Debug, Default, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -struct CodeModeExecPragma { - #[serde(default)] - yield_time_ms: Option, - #[serde(default)] - max_output_tokens: Option, -} - -#[derive(Debug, PartialEq, Eq)] -struct CodeModeExecArgs { - code: String, - yield_time_ms: Option, - max_output_tokens: Option, -} impl CodeModeExecuteHandler { async fn execute( &self, - session: std::sync::Arc, - turn: std::sync::Arc, + session: std::sync::Arc, + turn: std::sync::Arc, call_id: String, code: String, ) -> Result { - let args = parse_freeform_args(&code)?; + let args = + codex_code_mode::parse_exec_source(&code).map_err(FunctionCallError::RespondToModel)?; let exec = ExecContext { session, turn }; let enabled_tools = build_enabled_tools(&exec).await; - let service = &exec.session.services.code_mode_service; - let stored_values = service.stored_values().await; - let source = - build_source(&args.code, &enabled_tools).map_err(FunctionCallError::RespondToModel)?; - let cell_id = service.allocate_cell_id().await; - let request_id = service.allocate_request_id().await; - let process_slot = service - .ensure_started() - .await - .map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?; + let stored_values = exec + .session + .services + .code_mode_service + .stored_values() + .await; let started_at = std::time::Instant::now(); - let message = HostToNodeMessage::Start { - request_id: request_id.clone(), - cell_id: cell_id.clone(), - tool_call_id: call_id, - default_yield_time_ms: super::DEFAULT_EXEC_YIELD_TIME_MS, - enabled_tools, - stored_values, - source, - yield_time_ms: args.yield_time_ms, - max_output_tokens: args.max_output_tokens, - }; - let result = { - let mut process_slot = process_slot; - let Some(process) = process_slot.as_mut() else { - return Err(FunctionCallError::RespondToModel(format!( - "{PUBLIC_TOOL_NAME} runner failed to start" - ))); - }; - let message = process - .send(&request_id, &message) - .await - .map_err(|err| err.to_string()); - let message = match message { - Ok(message) => message, - Err(error) => return Err(FunctionCallError::RespondToModel(error)), - }; - handle_node_message( - &exec, cell_id, message, /*poll_max_output_tokens*/ None, started_at, - ) + let response = exec + .session + .services + .code_mode_service + .execute(codex_code_mode::ExecuteRequest { + tool_call_id: call_id, + enabled_tools, + source: args.code, + stored_values, + yield_time_ms: args.yield_time_ms, + max_output_tokens: args.max_output_tokens, + }) .await - }; - match result { - Ok(CodeModeSessionProgress::Finished(output)) - | Ok(CodeModeSessionProgress::Yielded { output }) => Ok(output), - Err(error) => Err(FunctionCallError::RespondToModel(error)), - } + .map_err(FunctionCallError::RespondToModel)?; + handle_runtime_response(&exec, response, args.max_output_tokens, started_at) + .await + .map_err(FunctionCallError::RespondToModel) } } -fn parse_freeform_args(input: &str) -> Result { - if input.trim().is_empty() { - return Err(FunctionCallError::RespondToModel( - "exec expects raw JavaScript source text (non-empty). Provide JS only, optionally with first-line `// @exec: {\"yield_time_ms\": 10000, \"max_output_tokens\": 1000}`.".to_string(), - )); - } - - let mut args = CodeModeExecArgs { - code: input.to_string(), - yield_time_ms: None, - max_output_tokens: None, - }; - - let mut lines = input.splitn(2, '\n'); - let first_line = lines.next().unwrap_or_default(); - let rest = lines.next().unwrap_or_default(); - let trimmed = first_line.trim_start(); - let Some(pragma) = trimmed.strip_prefix(CODE_MODE_PRAGMA_PREFIX) else { - return Ok(args); - }; - - if rest.trim().is_empty() { - return Err(FunctionCallError::RespondToModel( - "exec pragma must be followed by JavaScript source on subsequent lines".to_string(), - )); - } - - let directive = pragma.trim(); - if directive.is_empty() { - return Err(FunctionCallError::RespondToModel( - "exec pragma must be a JSON object with supported fields `yield_time_ms` and `max_output_tokens`" - .to_string(), - )); - } - - let value: serde_json::Value = serde_json::from_str(directive).map_err(|err| { - FunctionCallError::RespondToModel(format!( - "exec pragma must be valid JSON with supported fields `yield_time_ms` and `max_output_tokens`: {err}" - )) - })?; - let object = value.as_object().ok_or_else(|| { - FunctionCallError::RespondToModel( - "exec pragma must be a JSON object with supported fields `yield_time_ms` and `max_output_tokens`" - .to_string(), - ) - })?; - for key in object.keys() { - match key.as_str() { - "yield_time_ms" | "max_output_tokens" => {} - _ => { - return Err(FunctionCallError::RespondToModel(format!( - "exec pragma only supports `yield_time_ms` and `max_output_tokens`; got `{key}`" - ))); - } - } - } - - let pragma: CodeModeExecPragma = serde_json::from_value(value).map_err(|err| { - FunctionCallError::RespondToModel(format!( - "exec pragma fields `yield_time_ms` and `max_output_tokens` must be non-negative safe integers: {err}" - )) - })?; - if pragma - .yield_time_ms - .is_some_and(|yield_time_ms| yield_time_ms > MAX_JS_SAFE_INTEGER) - { - return Err(FunctionCallError::RespondToModel( - "exec pragma field `yield_time_ms` must be a non-negative safe integer".to_string(), - )); - } - if pragma.max_output_tokens.is_some_and(|max_output_tokens| { - u64::try_from(max_output_tokens) - .map(|max_output_tokens| max_output_tokens > MAX_JS_SAFE_INTEGER) - .unwrap_or(true) - }) { - return Err(FunctionCallError::RespondToModel( - "exec pragma field `max_output_tokens` must be a non-negative safe integer".to_string(), - )); - } - args.code = rest.to_string(); - args.yield_time_ms = pragma.yield_time_ms; - args.max_output_tokens = pragma.max_output_tokens; - Ok(args) -} - #[async_trait] impl ToolHandler for CodeModeExecuteHandler { type Output = FunctionToolOutput; @@ -216,7 +85,3 @@ impl ToolHandler for CodeModeExecuteHandler { } } } - -#[cfg(test)] -#[path = "execute_handler_tests.rs"] -mod execute_handler_tests; diff --git a/codex-rs/core/src/tools/code_mode/mod.rs b/codex-rs/core/src/tools/code_mode/mod.rs index c8e1e0c165..a4838d2463 100644 --- a/codex-rs/core/src/tools/code_mode/mod.rs +++ b/codex-rs/core/src/tools/code_mode/mod.rs @@ -1,15 +1,18 @@ mod execute_handler; -mod process; -mod protocol; -mod service; +mod response_adapter; mod wait_handler; -mod worker; +use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; +use codex_code_mode::CodeModeTurnHost; +use codex_code_mode::RuntimeResponse; use codex_protocol::models::FunctionCallOutputContentItem; +use codex_protocol::models::FunctionCallOutputPayload; +use codex_protocol::models::ResponseInputItem; use serde_json::Value as JsonValue; +use tokio_util::sync::CancellationToken; use crate::client_common::tools::ToolSpec; use crate::codex::Session; @@ -17,9 +20,8 @@ use crate::codex::TurnContext; use crate::function_tool::FunctionCallError; use crate::tools::ToolRouter; use crate::tools::code_mode_description::augment_tool_spec_for_code_mode; -use crate::tools::code_mode_description::code_mode_tool_reference; -use crate::tools::code_mode_description::normalize_code_mode_identifier; use crate::tools::context::FunctionToolOutput; +use crate::tools::context::SharedTurnDiffTracker; use crate::tools::context::ToolPayload; use crate::tools::parallel::ToolCallRuntime; use crate::tools::router::ToolCall; @@ -29,180 +31,202 @@ use crate::truncate::TruncationPolicy; use crate::truncate::formatted_truncate_text_content_items_with_policy; use crate::truncate::truncate_function_output_items_with_policy; use crate::unified_exec::resolve_max_tokens; +use codex_features::Feature; -const CODE_MODE_RUNNER_SOURCE: &str = include_str!("runner.cjs"); -const CODE_MODE_BRIDGE_SOURCE: &str = include_str!("bridge.js"); -const CODE_MODE_DESCRIPTION_TEMPLATE: &str = include_str!("description.md"); -const CODE_MODE_WAIT_DESCRIPTION_TEMPLATE: &str = include_str!("wait_description.md"); -const CODE_MODE_PRAGMA_PREFIX: &str = "// @exec:"; -const CODE_MODE_ONLY_PREFACE: &str = - "Use `exec/wait` tool to run all other tools, do not attempt to use any other tools directly"; +pub(crate) use execute_handler::CodeModeExecuteHandler; +use response_adapter::into_function_call_output_content_items; +pub(crate) use wait_handler::CodeModeWaitHandler; -pub(crate) const PUBLIC_TOOL_NAME: &str = "exec"; -pub(crate) const WAIT_TOOL_NAME: &str = "wait"; - -pub(crate) fn is_code_mode_nested_tool(tool_name: &str) -> bool { - tool_name != PUBLIC_TOOL_NAME && tool_name != WAIT_TOOL_NAME -} -pub(crate) const DEFAULT_EXEC_YIELD_TIME_MS: u64 = 10_000; -pub(crate) const DEFAULT_WAIT_YIELD_TIME_MS: u64 = 10_000; +pub(crate) const PUBLIC_TOOL_NAME: &str = codex_code_mode::PUBLIC_TOOL_NAME; +pub(crate) const WAIT_TOOL_NAME: &str = codex_code_mode::WAIT_TOOL_NAME; +pub(crate) const DEFAULT_WAIT_YIELD_TIME_MS: u64 = codex_code_mode::DEFAULT_WAIT_YIELD_TIME_MS; #[derive(Clone)] -pub(super) struct ExecContext { +pub(crate) struct ExecContext { pub(super) session: Arc, pub(super) turn: Arc, } -pub(crate) use execute_handler::CodeModeExecuteHandler; -pub(crate) use service::CodeModeService; -pub(crate) use wait_handler::CodeModeWaitHandler; - -enum CodeModeSessionProgress { - Finished(FunctionToolOutput), - Yielded { output: FunctionToolOutput }, +pub(crate) struct CodeModeService { + inner: codex_code_mode::CodeModeService, } -enum CodeModeExecutionStatus { - Completed, - Failed, - Running(String), - Terminated, -} - -pub(crate) fn tool_description(enabled_tools: &[(String, String)], code_mode_only: bool) -> String { - let description_template = CODE_MODE_DESCRIPTION_TEMPLATE.trim_end(); - if !code_mode_only { - return description_template.to_string(); +impl CodeModeService { + pub(crate) fn new(_js_repl_node_path: Option) -> Self { + Self { + inner: codex_code_mode::CodeModeService::new(), + } } - let mut sections = vec![ - CODE_MODE_ONLY_PREFACE.to_string(), - description_template.to_string(), - ]; + pub(crate) async fn stored_values(&self) -> std::collections::HashMap { + self.inner.stored_values().await + } - if !enabled_tools.is_empty() { - let nested_tool_reference = enabled_tools - .iter() - .map(|(name, nested_description)| { - let global_name = normalize_code_mode_identifier(name); - format!( - "### `{global_name}` (`{name}`)\n{}", - nested_description.trim() - ) + pub(crate) async fn replace_stored_values( + &self, + values: std::collections::HashMap, + ) { + self.inner.replace_stored_values(values).await; + } + + pub(crate) async fn execute( + &self, + request: codex_code_mode::ExecuteRequest, + ) -> Result { + self.inner.execute(request).await + } + + pub(crate) async fn wait( + &self, + request: codex_code_mode::WaitRequest, + ) -> Result { + self.inner.wait(request).await + } + + pub(crate) async fn start_turn_worker( + &self, + session: &Arc, + turn: &Arc, + router: Arc, + tracker: SharedTurnDiffTracker, + ) -> Option { + if !turn.features.enabled(Feature::CodeMode) { + return None; + } + + let exec = ExecContext { + session: Arc::clone(session), + turn: Arc::clone(turn), + }; + let tool_runtime = + ToolCallRuntime::new(router, Arc::clone(session), Arc::clone(turn), tracker); + let host = Arc::new(CoreTurnHost { exec, tool_runtime }); + Some(self.inner.start_turn_worker(host)) + } +} + +struct CoreTurnHost { + exec: ExecContext, + tool_runtime: ToolCallRuntime, +} + +#[async_trait::async_trait] +impl CodeModeTurnHost for CoreTurnHost { + async fn invoke_tool( + &self, + tool_name: String, + input: Option, + cancellation_token: CancellationToken, + ) -> Result { + call_nested_tool( + self.exec.clone(), + self.tool_runtime.clone(), + tool_name, + input, + cancellation_token, + ) + .await + .map_err(|error| error.to_string()) + } + + async fn notify(&self, call_id: String, cell_id: String, text: String) -> Result<(), String> { + if text.trim().is_empty() { + return Ok(()); + } + self.exec + .session + .inject_response_items(vec![ResponseInputItem::CustomToolCallOutput { + call_id, + name: Some(PUBLIC_TOOL_NAME.to_string()), + output: FunctionCallOutputPayload::from_text(text), + }]) + .await + .map_err(|_| { + format!("failed to inject exec notify message for cell {cell_id}: no active turn") }) - .collect::>() - .join("\n\n"); - sections.push(nested_tool_reference); } - - sections.join("\n\n") } -pub(crate) fn wait_tool_description() -> &'static str { - CODE_MODE_WAIT_DESCRIPTION_TEMPLATE -} - -async fn handle_node_message( +pub(super) async fn handle_runtime_response( exec: &ExecContext, - cell_id: String, - message: protocol::NodeToHostMessage, - poll_max_output_tokens: Option>, + response: RuntimeResponse, + max_output_tokens: Option, started_at: std::time::Instant, -) -> Result { - match message { - protocol::NodeToHostMessage::ToolCall { .. } => Err(protocol::unexpected_tool_call_error()), - protocol::NodeToHostMessage::Notify { .. } => Err(format!( - "unexpected {PUBLIC_TOOL_NAME} notify message in response path" - )), - protocol::NodeToHostMessage::Yielded { content_items, .. } => { - let mut delta_items = output_content_items_from_json_values(content_items)?; - delta_items = truncate_code_mode_result(delta_items, poll_max_output_tokens.flatten()); - prepend_script_status( - &mut delta_items, - CodeModeExecutionStatus::Running(cell_id), - started_at.elapsed(), - ); - Ok(CodeModeSessionProgress::Yielded { - output: FunctionToolOutput::from_content(delta_items, Some(true)), - }) +) -> Result { + let script_status = format_script_status(&response); + + match response { + RuntimeResponse::Yielded { content_items, .. } => { + let mut content_items = into_function_call_output_content_items(content_items); + content_items = truncate_code_mode_result(content_items, max_output_tokens); + prepend_script_status(&mut content_items, &script_status, started_at.elapsed()); + Ok(FunctionToolOutput::from_content(content_items, Some(true))) } - protocol::NodeToHostMessage::Terminated { content_items, .. } => { - let mut delta_items = output_content_items_from_json_values(content_items)?; - delta_items = truncate_code_mode_result(delta_items, poll_max_output_tokens.flatten()); - prepend_script_status( - &mut delta_items, - CodeModeExecutionStatus::Terminated, - started_at.elapsed(), - ); - Ok(CodeModeSessionProgress::Finished( - FunctionToolOutput::from_content(delta_items, Some(true)), - )) + RuntimeResponse::Terminated { content_items, .. } => { + let mut content_items = into_function_call_output_content_items(content_items); + content_items = truncate_code_mode_result(content_items, max_output_tokens); + prepend_script_status(&mut content_items, &script_status, started_at.elapsed()); + Ok(FunctionToolOutput::from_content(content_items, Some(true))) } - protocol::NodeToHostMessage::Result { + RuntimeResponse::Result { content_items, stored_values, error_text, - max_output_tokens_per_exec_call, .. } => { + let mut content_items = into_function_call_output_content_items(content_items); exec.session .services .code_mode_service .replace_stored_values(stored_values) .await; - let mut delta_items = output_content_items_from_json_values(content_items)?; let success = error_text.is_none(); if let Some(error_text) = error_text { - delta_items.push(FunctionCallOutputContentItem::InputText { + content_items.push(FunctionCallOutputContentItem::InputText { text: format!("Script error:\n{error_text}"), }); } - - let mut delta_items = truncate_code_mode_result( - delta_items, - poll_max_output_tokens.unwrap_or(max_output_tokens_per_exec_call), - ); - prepend_script_status( - &mut delta_items, - if success { - CodeModeExecutionStatus::Completed - } else { - CodeModeExecutionStatus::Failed - }, - started_at.elapsed(), - ); - Ok(CodeModeSessionProgress::Finished( - FunctionToolOutput::from_content(delta_items, Some(success)), + content_items = truncate_code_mode_result(content_items, max_output_tokens); + prepend_script_status(&mut content_items, &script_status, started_at.elapsed()); + Ok(FunctionToolOutput::from_content( + content_items, + Some(success), )) } } } +fn format_script_status(response: &RuntimeResponse) -> String { + match response { + RuntimeResponse::Yielded { cell_id, .. } => { + format!("Script running with cell ID {cell_id}") + } + RuntimeResponse::Terminated { .. } => "Script terminated".to_string(), + RuntimeResponse::Result { error_text, .. } => { + if error_text.is_none() { + "Script completed".to_string() + } else { + "Script failed".to_string() + } + } + } +} + fn prepend_script_status( content_items: &mut Vec, - status: CodeModeExecutionStatus, + status: &str, wall_time: Duration, ) { let wall_time_seconds = ((wall_time.as_secs_f32()) * 10.0).round() / 10.0; - let header = format!( - "{}\nWall time {wall_time_seconds:.1} seconds\nOutput:\n", - match status { - CodeModeExecutionStatus::Completed => "Script completed".to_string(), - CodeModeExecutionStatus::Failed => "Script failed".to_string(), - CodeModeExecutionStatus::Running(cell_id) => { - format!("Script running with cell ID {cell_id}") - } - CodeModeExecutionStatus::Terminated => "Script terminated".to_string(), - } - ); + let header = format!("{status}\nWall time {wall_time_seconds:.1} seconds\nOutput:\n"); content_items.insert(0, FunctionCallOutputContentItem::InputText { text: header }); } fn truncate_code_mode_result( items: Vec, - max_output_tokens_per_exec_call: Option, + max_output_tokens: Option, ) -> Vec { - let max_output_tokens = resolve_max_tokens(max_output_tokens_per_exec_call); + let max_output_tokens = resolve_max_tokens(max_output_tokens); let policy = TruncationPolicy::Tokens(max_output_tokens); if items .iter() @@ -216,21 +240,9 @@ fn truncate_code_mode_result( truncate_function_output_items_with_policy(&items, policy) } -fn output_content_items_from_json_values( - content_items: Vec, -) -> Result, String> { - content_items - .into_iter() - .enumerate() - .map(|(index, item)| { - serde_json::from_value(item).map_err(|err| { - format!("invalid {PUBLIC_TOOL_NAME} content item at index {index}: {err}") - }) - }) - .collect() -} - -async fn build_enabled_tools(exec: &ExecContext) -> Vec { +pub(super) async fn build_enabled_tools( + exec: &ExecContext, +) -> Vec { let router = build_nested_router(exec).await; let mut out = router .specs() @@ -238,39 +250,37 @@ async fn build_enabled_tools(exec: &ExecContext) -> Vec { .map(|spec| augment_tool_spec_for_code_mode(spec, /*code_mode_enabled*/ true)) .filter_map(enabled_tool_from_spec) .collect::>(); - out.sort_by(|left, right| left.tool_name.cmp(&right.tool_name)); - out.dedup_by(|left, right| left.tool_name == right.tool_name); + out.sort_by(|left, right| left.name.cmp(&right.name)); + out.dedup_by(|left, right| left.name == right.name); out } -fn enabled_tool_from_spec(spec: ToolSpec) -> Option { +fn enabled_tool_from_spec(spec: ToolSpec) -> Option { let tool_name = spec.name().to_string(); - if !is_code_mode_nested_tool(&tool_name) { + if !codex_code_mode::is_code_mode_nested_tool(&tool_name) { return None; } - let reference = code_mode_tool_reference(&tool_name); - let global_name = normalize_code_mode_identifier(&tool_name); - let (description, kind) = match spec { - ToolSpec::Function(tool) => (tool.description, protocol::CodeModeToolKind::Function), - ToolSpec::Freeform(tool) => (tool.description, protocol::CodeModeToolKind::Freeform), + match spec { + ToolSpec::Function(tool) => Some(codex_code_mode::ToolDefinition { + name: tool_name, + description: tool.description, + kind: codex_code_mode::CodeModeToolKind::Function, + input_schema: serde_json::to_value(&tool.parameters).ok(), + output_schema: tool.output_schema, + }), + ToolSpec::Freeform(tool) => Some(codex_code_mode::ToolDefinition { + name: tool_name, + description: tool.description, + kind: codex_code_mode::CodeModeToolKind::Freeform, + input_schema: None, + output_schema: None, + }), ToolSpec::LocalShell {} | ToolSpec::ImageGeneration { .. } | ToolSpec::ToolSearch { .. } - | ToolSpec::WebSearch { .. } => { - return None; - } - }; - - Some(protocol::EnabledTool { - tool_name, - global_name, - module_path: reference.module_path, - namespace: reference.namespace, - name: normalize_code_mode_identifier(&reference.tool_key), - description, - kind, - }) + | ToolSpec::WebSearch { .. } => None, + } } async fn build_nested_router(exec: &ExecContext) -> ToolRouter { @@ -303,7 +313,7 @@ async fn call_nested_tool( tool_runtime: ToolCallRuntime, tool_name: String, input: Option, - cancellation_token: tokio_util::sync::CancellationToken, + cancellation_token: CancellationToken, ) -> Result { if tool_name == PUBLIC_TOOL_NAME { return Err(FunctionCallError::RespondToModel(format!( @@ -340,18 +350,18 @@ async fn call_nested_tool( Ok(result.code_mode_result()) } -fn tool_kind_for_spec(spec: &ToolSpec) -> protocol::CodeModeToolKind { +fn tool_kind_for_spec(spec: &ToolSpec) -> codex_code_mode::CodeModeToolKind { if matches!(spec, ToolSpec::Freeform(_)) { - protocol::CodeModeToolKind::Freeform + codex_code_mode::CodeModeToolKind::Freeform } else { - protocol::CodeModeToolKind::Function + codex_code_mode::CodeModeToolKind::Function } } fn tool_kind_for_name( spec: Option, tool_name: &str, -) -> Result { +) -> Result { spec.as_ref() .map(tool_kind_for_spec) .ok_or_else(|| format!("tool `{tool_name}` is not enabled in {PUBLIC_TOOL_NAME}")) @@ -364,8 +374,12 @@ fn build_nested_tool_payload( ) -> Result { let actual_kind = tool_kind_for_name(spec, tool_name)?; match actual_kind { - protocol::CodeModeToolKind::Function => build_function_tool_payload(tool_name, input), - protocol::CodeModeToolKind::Freeform => build_freeform_tool_payload(tool_name, input), + codex_code_mode::CodeModeToolKind::Function => { + build_function_tool_payload(tool_name, input) + } + codex_code_mode::CodeModeToolKind::Freeform => { + build_freeform_tool_payload(tool_name, input) + } } } diff --git a/codex-rs/core/src/tools/code_mode/process.rs b/codex-rs/core/src/tools/code_mode/process.rs deleted file mode 100644 index 6dd6cde3ae..0000000000 --- a/codex-rs/core/src/tools/code_mode/process.rs +++ /dev/null @@ -1,173 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use tokio::io::AsyncBufReadExt; -use tokio::io::AsyncReadExt; -use tokio::io::AsyncWriteExt; -use tokio::io::BufReader; -use tokio::sync::Mutex; -use tokio::sync::mpsc; -use tokio::sync::oneshot; -use tokio::task::JoinHandle; -use tracing::warn; - -use super::CODE_MODE_RUNNER_SOURCE; -use super::PUBLIC_TOOL_NAME; -use super::protocol::HostToNodeMessage; -use super::protocol::NodeToHostMessage; -use super::protocol::message_request_id; - -pub(super) struct CodeModeProcess { - pub(super) child: tokio::process::Child, - pub(super) stdin: Arc>, - pub(super) stdout_task: JoinHandle<()>, - pub(super) response_waiters: Arc>>>, - pub(super) message_rx: Arc>>, -} - -impl CodeModeProcess { - pub(super) async fn send( - &mut self, - request_id: &str, - message: &HostToNodeMessage, - ) -> Result { - if self.stdout_task.is_finished() { - return Err(std::io::Error::other(format!( - "{PUBLIC_TOOL_NAME} runner is not available" - ))); - } - - let (tx, rx) = oneshot::channel(); - self.response_waiters - .lock() - .await - .insert(request_id.to_string(), tx); - if let Err(err) = write_message(&self.stdin, message).await { - self.response_waiters.lock().await.remove(request_id); - return Err(err); - } - - match rx.await { - Ok(message) => Ok(message), - Err(_) => Err(std::io::Error::other(format!( - "{PUBLIC_TOOL_NAME} runner is not available" - ))), - } - } - - pub(super) fn has_exited(&mut self) -> Result { - self.child - .try_wait() - .map(|status| status.is_some()) - .map_err(std::io::Error::other) - } -} - -pub(super) async fn spawn_code_mode_process( - node_path: &std::path::Path, -) -> Result { - let mut cmd = tokio::process::Command::new(node_path); - cmd.arg("--experimental-vm-modules"); - cmd.arg("--eval"); - cmd.arg(CODE_MODE_RUNNER_SOURCE); - cmd.stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .kill_on_drop(true); - - let mut child = cmd.spawn().map_err(std::io::Error::other)?; - let stdout = child.stdout.take().ok_or_else(|| { - std::io::Error::other(format!("{PUBLIC_TOOL_NAME} runner missing stdout")) - })?; - let stderr = child.stderr.take().ok_or_else(|| { - std::io::Error::other(format!("{PUBLIC_TOOL_NAME} runner missing stderr")) - })?; - let stdin = child - .stdin - .take() - .ok_or_else(|| std::io::Error::other(format!("{PUBLIC_TOOL_NAME} runner missing stdin")))?; - let stdin = Arc::new(Mutex::new(stdin)); - let response_waiters = Arc::new(Mutex::new(HashMap::< - String, - oneshot::Sender, - >::new())); - let (message_tx, message_rx) = mpsc::unbounded_channel(); - - tokio::spawn(async move { - let mut reader = BufReader::new(stderr); - let mut buf = Vec::new(); - match reader.read_to_end(&mut buf).await { - Ok(_) => { - let stderr = String::from_utf8_lossy(&buf).trim().to_string(); - if !stderr.is_empty() { - warn!("{PUBLIC_TOOL_NAME} runner stderr: {stderr}"); - } - } - Err(err) => { - warn!("failed to read {PUBLIC_TOOL_NAME} stderr: {err}"); - } - } - }); - let stdout_task = tokio::spawn({ - let response_waiters = Arc::clone(&response_waiters); - async move { - let mut stdout_lines = BufReader::new(stdout).lines(); - loop { - let line = match stdout_lines.next_line().await { - Ok(line) => line, - Err(err) => { - warn!("failed to read {PUBLIC_TOOL_NAME} stdout: {err}"); - break; - } - }; - let Some(line) = line else { - break; - }; - if line.trim().is_empty() { - continue; - } - let message: NodeToHostMessage = match serde_json::from_str(&line) { - Ok(message) => message, - Err(err) => { - warn!("failed to parse {PUBLIC_TOOL_NAME} stdout message: {err}"); - break; - } - }; - match message { - message @ (NodeToHostMessage::ToolCall { .. } - | NodeToHostMessage::Notify { .. }) => { - let _ = message_tx.send(message); - } - message => { - if let Some(request_id) = message_request_id(&message) - && let Some(waiter) = response_waiters.lock().await.remove(request_id) - { - let _ = waiter.send(message); - } - } - } - } - response_waiters.lock().await.clear(); - } - }); - - Ok(CodeModeProcess { - child, - stdin, - stdout_task, - response_waiters, - message_rx: Arc::new(Mutex::new(message_rx)), - }) -} - -pub(super) async fn write_message( - stdin: &Arc>, - message: &HostToNodeMessage, -) -> Result<(), std::io::Error> { - let line = serde_json::to_string(message).map_err(std::io::Error::other)?; - let mut stdin = stdin.lock().await; - stdin.write_all(line.as_bytes()).await?; - stdin.write_all(b"\n").await?; - stdin.flush().await?; - Ok(()) -} diff --git a/codex-rs/core/src/tools/code_mode/protocol.rs b/codex-rs/core/src/tools/code_mode/protocol.rs deleted file mode 100644 index 2e72e1229c..0000000000 --- a/codex-rs/core/src/tools/code_mode/protocol.rs +++ /dev/null @@ -1,169 +0,0 @@ -use std::collections::HashMap; - -use serde::Deserialize; -use serde::Serialize; -use serde_json::Value as JsonValue; - -use super::CODE_MODE_BRIDGE_SOURCE; -use super::PUBLIC_TOOL_NAME; - -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "snake_case")] -pub(super) enum CodeModeToolKind { - Function, - Freeform, -} - -#[derive(Clone, Debug, Serialize)] -pub(super) struct EnabledTool { - pub(super) tool_name: String, - pub(super) global_name: String, - #[serde(rename = "module")] - pub(super) module_path: String, - pub(super) namespace: Vec, - pub(super) name: String, - pub(super) description: String, - pub(super) kind: CodeModeToolKind, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "snake_case")] -pub(super) struct CodeModeToolCall { - pub(super) request_id: String, - pub(super) id: String, - pub(super) name: String, - #[serde(default)] - pub(super) input: Option, -} - -#[derive(Clone, Debug, Deserialize)] -pub(super) struct CodeModeNotify { - pub(super) cell_id: String, - pub(super) call_id: String, - pub(super) text: String, -} - -#[derive(Serialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub(super) enum HostToNodeMessage { - Start { - request_id: String, - cell_id: String, - tool_call_id: String, - default_yield_time_ms: u64, - enabled_tools: Vec, - stored_values: HashMap, - source: String, - yield_time_ms: Option, - max_output_tokens: Option, - }, - Poll { - request_id: String, - cell_id: String, - yield_time_ms: u64, - }, - Terminate { - request_id: String, - cell_id: String, - }, - Response { - request_id: String, - id: String, - code_mode_result: JsonValue, - #[serde(default)] - error_text: Option, - }, -} - -#[derive(Debug, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub(super) enum NodeToHostMessage { - ToolCall { - #[serde(flatten)] - tool_call: CodeModeToolCall, - }, - Yielded { - request_id: String, - content_items: Vec, - }, - Terminated { - request_id: String, - content_items: Vec, - }, - Notify { - #[serde(flatten)] - notify: CodeModeNotify, - }, - Result { - request_id: String, - content_items: Vec, - stored_values: HashMap, - #[serde(default)] - error_text: Option, - #[serde(default)] - max_output_tokens_per_exec_call: Option, - }, -} - -pub(super) fn build_source( - user_code: &str, - enabled_tools: &[EnabledTool], -) -> Result { - let enabled_tools_json = serde_json::to_string(enabled_tools) - .map_err(|err| format!("failed to serialize enabled tools: {err}"))?; - Ok(CODE_MODE_BRIDGE_SOURCE - .replace( - "__CODE_MODE_ENABLED_TOOLS_PLACEHOLDER__", - &enabled_tools_json, - ) - .replace("__CODE_MODE_USER_CODE_PLACEHOLDER__", user_code)) -} - -pub(super) fn message_request_id(message: &NodeToHostMessage) -> Option<&str> { - match message { - NodeToHostMessage::ToolCall { .. } => None, - NodeToHostMessage::Yielded { request_id, .. } - | NodeToHostMessage::Terminated { request_id, .. } - | NodeToHostMessage::Result { request_id, .. } => Some(request_id), - NodeToHostMessage::Notify { .. } => None, - } -} - -pub(super) fn unexpected_tool_call_error() -> String { - format!("{PUBLIC_TOOL_NAME} received an unexpected tool call response") -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - - use super::CodeModeNotify; - use super::NodeToHostMessage; - use super::message_request_id; - - #[test] - fn message_request_id_absent_for_notify() { - let message = NodeToHostMessage::Notify { - notify: CodeModeNotify { - cell_id: "1".to_string(), - call_id: "call-1".to_string(), - text: "hello".to_string(), - }, - }; - - assert_eq!(None, message_request_id(&message)); - } - - #[test] - fn message_request_id_present_for_result() { - let message = NodeToHostMessage::Result { - request_id: "req-1".to_string(), - content_items: Vec::new(), - stored_values: HashMap::new(), - error_text: None, - max_output_tokens_per_exec_call: None, - }; - - assert_eq!(Some("req-1"), message_request_id(&message)); - } -} diff --git a/codex-rs/core/src/tools/code_mode/response_adapter.rs b/codex-rs/core/src/tools/code_mode/response_adapter.rs new file mode 100644 index 0000000000..b90448acf9 --- /dev/null +++ b/codex-rs/core/src/tools/code_mode/response_adapter.rs @@ -0,0 +1,44 @@ +use codex_code_mode::ImageDetail as CodeModeImageDetail; +use codex_protocol::models::FunctionCallOutputContentItem; +use codex_protocol::models::ImageDetail; + +trait IntoProtocol { + fn into_protocol(self) -> T; +} + +pub(super) fn into_function_call_output_content_items( + items: Vec, +) -> Vec { + items.into_iter().map(IntoProtocol::into_protocol).collect() +} + +impl IntoProtocol for CodeModeImageDetail { + fn into_protocol(self) -> ImageDetail { + let value = self; + match value { + CodeModeImageDetail::Auto => ImageDetail::Auto, + CodeModeImageDetail::Low => ImageDetail::Low, + CodeModeImageDetail::High => ImageDetail::High, + CodeModeImageDetail::Original => ImageDetail::Original, + } + } +} + +impl IntoProtocol + for codex_code_mode::FunctionCallOutputContentItem +{ + fn into_protocol(self) -> FunctionCallOutputContentItem { + let value = self; + match value { + codex_code_mode::FunctionCallOutputContentItem::InputText { text } => { + FunctionCallOutputContentItem::InputText { text } + } + codex_code_mode::FunctionCallOutputContentItem::InputImage { image_url, detail } => { + FunctionCallOutputContentItem::InputImage { + image_url, + detail: detail.map(IntoProtocol::into_protocol), + } + } + } + } +} diff --git a/codex-rs/core/src/tools/code_mode/runner.cjs b/codex-rs/core/src/tools/code_mode/runner.cjs deleted file mode 100644 index 8b4b322eb3..0000000000 --- a/codex-rs/core/src/tools/code_mode/runner.cjs +++ /dev/null @@ -1,938 +0,0 @@ -'use strict'; - -const readline = require('node:readline'); -const { Worker } = require('node:worker_threads'); - -const DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL = 10000; - -function normalizeMaxOutputTokensPerExecCall(value) { - if (!Number.isSafeInteger(value) || value < 0) { - throw new TypeError('max_output_tokens_per_exec_call must be a non-negative safe integer'); - } - return value; -} - -function normalizeYieldTime(value) { - if (!Number.isSafeInteger(value) || value < 0) { - throw new TypeError('yield_time must be a non-negative safe integer'); - } - return value; -} - -function formatErrorText(error) { - return String(error && error.stack ? error.stack : error); -} - -function cloneJsonValue(value) { - return JSON.parse(JSON.stringify(value)); -} - -function clearTimer(timer) { - if (timer !== null) { - clearTimeout(timer); - } - return null; -} - -function takeContentItems(session) { - const clonedContentItems = cloneJsonValue(session.content_items); - session.content_items.splice(0, session.content_items.length); - return Array.isArray(clonedContentItems) ? clonedContentItems : []; -} - -function codeModeWorkerMain() { - 'use strict'; - - const { parentPort, workerData } = require('node:worker_threads'); - const vm = require('node:vm'); - const { SourceTextModule, SyntheticModule } = vm; - - function formatErrorText(error) { - return String(error && error.stack ? error.stack : error); - } - - function cloneJsonValue(value) { - return JSON.parse(JSON.stringify(value)); - } - - class CodeModeExitSignal extends Error { - constructor() { - super('code mode exit'); - this.name = 'CodeModeExitSignal'; - } - } - - function isCodeModeExitSignal(error) { - return error instanceof CodeModeExitSignal; - } - - function createToolCaller() { - let nextId = 0; - const pending = new Map(); - - parentPort.on('message', (message) => { - if (message.type === 'tool_response') { - const entry = pending.get(message.id); - if (!entry) { - return; - } - pending.delete(message.id); - entry.resolve(message.result ?? ''); - return; - } - - if (message.type === 'tool_response_error') { - const entry = pending.get(message.id); - if (!entry) { - return; - } - pending.delete(message.id); - entry.reject(new Error(message.error_text ?? 'tool call failed')); - return; - } - }); - - return (name, input) => { - const id = 'msg-' + ++nextId; - return new Promise((resolve, reject) => { - pending.set(id, { resolve, reject }); - parentPort.postMessage({ - type: 'tool_call', - id, - name: String(name), - input, - }); - }); - }; - } - - function createContentItems() { - const contentItems = []; - const push = contentItems.push.bind(contentItems); - contentItems.push = (...items) => { - for (const item of items) { - parentPort.postMessage({ - type: 'content_item', - item: cloneJsonValue(item), - }); - } - return push(...items); - }; - parentPort.on('message', (message) => { - if (message.type === 'clear_content') { - contentItems.splice(0, contentItems.length); - } - }); - return contentItems; - } - - function createGlobalToolsNamespace(callTool, enabledTools) { - const tools = Object.create(null); - - for (const { tool_name, global_name } of enabledTools) { - Object.defineProperty(tools, global_name, { - value: async (args) => callTool(tool_name, args), - configurable: false, - enumerable: true, - writable: false, - }); - } - - return Object.freeze(tools); - } - - function createModuleToolsNamespace(callTool, enabledTools) { - const tools = Object.create(null); - - for (const { tool_name, global_name } of enabledTools) { - Object.defineProperty(tools, global_name, { - value: async (args) => callTool(tool_name, args), - configurable: false, - enumerable: true, - writable: false, - }); - } - - return Object.freeze(tools); - } - - function createAllToolsMetadata(enabledTools) { - return Object.freeze( - enabledTools.map(({ global_name, description }) => - Object.freeze({ - name: global_name, - description, - }) - ) - ); - } - - function createToolsModule(context, callTool, enabledTools) { - const tools = createModuleToolsNamespace(callTool, enabledTools); - const allTools = createAllToolsMetadata(enabledTools); - const exportNames = ['ALL_TOOLS']; - - for (const { global_name } of enabledTools) { - if (global_name !== 'ALL_TOOLS') { - exportNames.push(global_name); - } - } - - const uniqueExportNames = [...new Set(exportNames)]; - - return new SyntheticModule( - uniqueExportNames, - function initToolsModule() { - this.setExport('ALL_TOOLS', allTools); - for (const exportName of uniqueExportNames) { - if (exportName !== 'ALL_TOOLS') { - this.setExport(exportName, tools[exportName]); - } - } - }, - { context } - ); - } - - function ensureContentItems(context) { - if (!Array.isArray(context.__codexContentItems)) { - context.__codexContentItems = []; - } - return context.__codexContentItems; - } - - function serializeOutputText(value) { - if (typeof value === 'string') { - return value; - } - if ( - typeof value === 'undefined' || - value === null || - typeof value === 'boolean' || - typeof value === 'number' || - typeof value === 'bigint' - ) { - return String(value); - } - - const serialized = JSON.stringify(value); - if (typeof serialized === 'string') { - return serialized; - } - - return String(value); - } - - function normalizeOutputImage(value) { - let imageUrl; - let detail; - if (typeof value === 'string') { - imageUrl = value; - } else if ( - value && - typeof value === 'object' && - !Array.isArray(value) - ) { - if (typeof value.image_url === 'string') { - imageUrl = value.image_url; - } - if (typeof value.detail === 'string') { - detail = value.detail; - } else if ( - Object.prototype.hasOwnProperty.call(value, 'detail') && - value.detail !== null && - typeof value.detail !== 'undefined' - ) { - throw new TypeError('image detail must be a string when provided'); - } - } - - if (typeof imageUrl !== 'string' || !imageUrl) { - throw new TypeError( - 'image expects a non-empty image URL string or an object with image_url and optional detail' - ); - } - if (!/^(?:https?:\/\/|data:)/i.test(imageUrl)) { - throw new TypeError('image expects an http(s) or data URL'); - } - - if (typeof detail !== 'undefined' && !/^(?:auto|low|high|original)$/i.test(detail)) { - throw new TypeError('image detail must be one of: auto, low, high, original'); - } - - const normalized = { image_url: imageUrl }; - if (typeof detail === 'string') { - normalized.detail = detail.toLowerCase(); - } - return normalized; - } - - function createCodeModeHelpers(context, state, toolCallId) { - const load = (key) => { - if (typeof key !== 'string') { - throw new TypeError('load key must be a string'); - } - if (!Object.prototype.hasOwnProperty.call(state.storedValues, key)) { - return undefined; - } - return cloneJsonValue(state.storedValues[key]); - }; - const store = (key, value) => { - if (typeof key !== 'string') { - throw new TypeError('store key must be a string'); - } - state.storedValues[key] = cloneJsonValue(value); - }; - const text = (value) => { - const item = { - type: 'input_text', - text: serializeOutputText(value), - }; - ensureContentItems(context).push(item); - return item; - }; - const image = (value) => { - const item = Object.assign({ type: 'input_image' }, normalizeOutputImage(value)); - ensureContentItems(context).push(item); - return item; - }; - const yieldControl = () => { - parentPort.postMessage({ type: 'yield' }); - }; - const notify = (value) => { - const text = serializeOutputText(value); - if (text.trim().length === 0) { - throw new TypeError('notify expects non-empty text'); - } - if (typeof toolCallId !== 'string' || toolCallId.length === 0) { - throw new TypeError('notify requires a valid tool call id'); - } - parentPort.postMessage({ - type: 'notify', - call_id: toolCallId, - text, - }); - return text; - }; - const exit = () => { - throw new CodeModeExitSignal(); - }; - - return Object.freeze({ - exit, - image, - load, - notify, - output_image: image, - output_text: text, - store, - text, - yield_control: yieldControl, - }); - } - - function createCodeModeModule(context, helpers) { - return new SyntheticModule( - [ - 'exit', - 'image', - 'load', - 'notify', - 'output_text', - 'output_image', - 'store', - 'text', - 'yield_control', - ], - function initCodeModeModule() { - this.setExport('exit', helpers.exit); - this.setExport('image', helpers.image); - this.setExport('load', helpers.load); - this.setExport('notify', helpers.notify); - this.setExport('output_text', helpers.output_text); - this.setExport('output_image', helpers.output_image); - this.setExport('store', helpers.store); - this.setExport('text', helpers.text); - this.setExport('yield_control', helpers.yield_control); - }, - { context } - ); - } - - function createBridgeRuntime(callTool, enabledTools, helpers) { - return Object.freeze({ - ALL_TOOLS: createAllToolsMetadata(enabledTools), - exit: helpers.exit, - image: helpers.image, - load: helpers.load, - notify: helpers.notify, - store: helpers.store, - text: helpers.text, - tools: createGlobalToolsNamespace(callTool, enabledTools), - yield_control: helpers.yield_control, - }); - } - - function namespacesMatch(left, right) { - if (left.length !== right.length) { - return false; - } - return left.every((segment, index) => segment === right[index]); - } - - function createNamespacedToolsNamespace(callTool, enabledTools, namespace) { - const tools = Object.create(null); - - for (const tool of enabledTools) { - const toolNamespace = Array.isArray(tool.namespace) ? tool.namespace : []; - if (!namespacesMatch(toolNamespace, namespace)) { - continue; - } - - Object.defineProperty(tools, tool.name, { - value: async (args) => callTool(tool.tool_name, args), - configurable: false, - enumerable: true, - writable: false, - }); - } - - return Object.freeze(tools); - } - - function createNamespacedToolsModule(context, callTool, enabledTools, namespace) { - const tools = createNamespacedToolsNamespace(callTool, enabledTools, namespace); - const exportNames = []; - - for (const exportName of Object.keys(tools)) { - if (exportName !== 'ALL_TOOLS') { - exportNames.push(exportName); - } - } - - const uniqueExportNames = [...new Set(exportNames)]; - - return new SyntheticModule( - uniqueExportNames, - function initNamespacedToolsModule() { - for (const exportName of uniqueExportNames) { - this.setExport(exportName, tools[exportName]); - } - }, - { context } - ); - } - - function createModuleResolver(context, callTool, enabledTools, helpers) { - let toolsModule; - let codeModeModule; - const namespacedModules = new Map(); - - return function resolveModule(specifier) { - if (specifier === 'tools.js') { - toolsModule ??= createToolsModule(context, callTool, enabledTools); - return toolsModule; - } - if (specifier === '@openai/code_mode' || specifier === 'openai/code_mode') { - codeModeModule ??= createCodeModeModule(context, helpers); - return codeModeModule; - } - const namespacedMatch = /^tools\/(.+)\.js$/.exec(specifier); - if (!namespacedMatch) { - throw new Error('Unsupported import in exec: ' + specifier); - } - - const namespace = namespacedMatch[1] - .split('/') - .filter((segment) => segment.length > 0); - if (namespace.length === 0) { - throw new Error('Unsupported import in exec: ' + specifier); - } - - const cacheKey = namespace.join('/'); - if (!namespacedModules.has(cacheKey)) { - namespacedModules.set( - cacheKey, - createNamespacedToolsModule(context, callTool, enabledTools, namespace) - ); - } - return namespacedModules.get(cacheKey); - }; - } - - async function resolveDynamicModule(specifier, resolveModule) { - const module = resolveModule(specifier); - - if (module.status === 'unlinked') { - await module.link(resolveModule); - } - - if (module.status === 'linked' || module.status === 'evaluating') { - await module.evaluate(); - } - - if (module.status === 'errored') { - throw module.error; - } - - return module; - } - - async function runModule(context, start, callTool, helpers) { - const resolveModule = createModuleResolver( - context, - callTool, - start.enabled_tools ?? [], - helpers - ); - const mainModule = new SourceTextModule(start.source, { - context, - identifier: 'exec_main.mjs', - importModuleDynamically: async (specifier) => - resolveDynamicModule(specifier, resolveModule), - }); - - await mainModule.link(resolveModule); - await mainModule.evaluate(); - } - - async function main() { - const start = workerData ?? {}; - const toolCallId = start.tool_call_id; - const state = { - storedValues: cloneJsonValue(start.stored_values ?? {}), - }; - const callTool = createToolCaller(); - const enabledTools = start.enabled_tools ?? []; - const contentItems = createContentItems(); - const context = vm.createContext({ - __codexContentItems: contentItems, - }); - const helpers = createCodeModeHelpers(context, state, toolCallId); - Object.defineProperty(context, '__codexRuntime', { - value: createBridgeRuntime(callTool, enabledTools, helpers), - configurable: true, - enumerable: false, - writable: false, - }); - - parentPort.postMessage({ type: 'started' }); - try { - await runModule(context, start, callTool, helpers); - parentPort.postMessage({ - type: 'result', - stored_values: state.storedValues, - }); - } catch (error) { - if (isCodeModeExitSignal(error)) { - parentPort.postMessage({ - type: 'result', - stored_values: state.storedValues, - }); - return; - } - parentPort.postMessage({ - type: 'result', - stored_values: state.storedValues, - error_text: formatErrorText(error), - }); - } - } - - void main().catch((error) => { - parentPort.postMessage({ - type: 'result', - stored_values: {}, - error_text: formatErrorText(error), - }); - }); -} - -function createProtocol() { - const rl = readline.createInterface({ - input: process.stdin, - crlfDelay: Infinity, - }); - - let nextId = 0; - const pending = new Map(); - const sessions = new Map(); - let closedResolve; - const closed = new Promise((resolve) => { - closedResolve = resolve; - }); - - rl.on('line', (line) => { - if (!line.trim()) { - return; - } - - let message; - try { - message = JSON.parse(line); - } catch (error) { - process.stderr.write(formatErrorText(error) + '\n'); - return; - } - - if (message.type === 'start') { - startSession(protocol, sessions, message); - return; - } - - if (message.type === 'poll') { - const session = sessions.get(message.cell_id); - if (session) { - session.request_id = String(message.request_id); - if (session.pending_result) { - void completeSession(protocol, sessions, session, session.pending_result); - } else { - schedulePollYield(protocol, session, normalizeYieldTime(message.yield_time_ms ?? 0)); - } - } else { - void protocol.send({ - type: 'result', - request_id: message.request_id, - content_items: [], - stored_values: {}, - error_text: `exec cell ${message.cell_id} not found`, - max_output_tokens_per_exec_call: DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL, - }); - } - return; - } - - if (message.type === 'terminate') { - const session = sessions.get(message.cell_id); - if (session) { - session.request_id = String(message.request_id); - void terminateSession(protocol, sessions, session); - } else { - void protocol.send({ - type: 'result', - request_id: message.request_id, - content_items: [], - stored_values: {}, - error_text: `exec cell ${message.cell_id} not found`, - max_output_tokens_per_exec_call: DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL, - }); - } - return; - } - - if (message.type === 'response') { - const entry = pending.get(message.request_id + ':' + message.id); - if (!entry) { - return; - } - pending.delete(message.request_id + ':' + message.id); - if (typeof message.error_text === 'string') { - entry.reject(new Error(message.error_text)); - return; - } - entry.resolve(message.code_mode_result ?? ''); - return; - } - - process.stderr.write('Unknown protocol message type: ' + message.type + '\n'); - }); - - rl.on('close', () => { - const error = new Error('stdin closed'); - for (const entry of pending.values()) { - entry.reject(error); - } - pending.clear(); - for (const session of sessions.values()) { - session.initial_yield_timer = clearTimer(session.initial_yield_timer); - session.poll_yield_timer = clearTimer(session.poll_yield_timer); - void session.worker.terminate().catch(() => {}); - } - sessions.clear(); - closedResolve(); - }); - - function send(message) { - return new Promise((resolve, reject) => { - process.stdout.write(JSON.stringify(message) + '\n', (error) => { - if (error) { - reject(error); - } else { - resolve(); - } - }); - }); - } - - function request(type, payload) { - const requestId = 'req-' + ++nextId; - const id = 'msg-' + ++nextId; - const pendingKey = requestId + ':' + id; - return new Promise((resolve, reject) => { - pending.set(pendingKey, { resolve, reject }); - void send({ type, request_id: requestId, id, ...payload }).catch((error) => { - pending.delete(pendingKey); - reject(error); - }); - }); - } - - const protocol = { closed, request, send }; - return protocol; -} - -function sessionWorkerSource() { - return '(' + codeModeWorkerMain.toString() + ')();'; -} - -function startSession(protocol, sessions, start) { - if (typeof start.tool_call_id !== 'string' || start.tool_call_id.length === 0) { - throw new TypeError('start requires a valid tool_call_id'); - } - const maxOutputTokensPerExecCall = - start.max_output_tokens == null - ? DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL - : normalizeMaxOutputTokensPerExecCall(start.max_output_tokens); - const session = { - completed: false, - content_items: [], - default_yield_time_ms: normalizeYieldTime(start.default_yield_time_ms), - id: start.cell_id, - initial_yield_time_ms: - start.yield_time_ms == null - ? normalizeYieldTime(start.default_yield_time_ms) - : normalizeYieldTime(start.yield_time_ms), - initial_yield_timer: null, - initial_yield_triggered: false, - max_output_tokens_per_exec_call: maxOutputTokensPerExecCall, - pending_result: null, - poll_yield_timer: null, - request_id: String(start.request_id), - worker: new Worker(sessionWorkerSource(), { - eval: true, - workerData: start, - }), - }; - sessions.set(session.id, session); - - session.worker.on('message', (message) => { - void handleWorkerMessage(protocol, sessions, session, message).catch((error) => { - void completeSession(protocol, sessions, session, { - type: 'result', - stored_values: {}, - error_text: formatErrorText(error), - }); - }); - }); - session.worker.on('error', (error) => { - void completeSession(protocol, sessions, session, { - type: 'result', - stored_values: {}, - error_text: formatErrorText(error), - }); - }); - session.worker.on('exit', (code) => { - if (code !== 0 && !session.completed) { - void completeSession(protocol, sessions, session, { - type: 'result', - stored_values: {}, - error_text: 'exec worker exited with code ' + code, - }); - } - }); -} - -async function handleWorkerMessage(protocol, sessions, session, message) { - if (session.completed) { - return; - } - - if (message.type === 'content_item') { - session.content_items.push(cloneJsonValue(message.item)); - return; - } - - if (message.type === 'started') { - scheduleInitialYield(protocol, session, session.initial_yield_time_ms); - return; - } - - if (message.type === 'yield') { - void sendYielded(protocol, session); - return; - } - - if (message.type === 'notify') { - if (typeof message.text !== 'string' || message.text.trim().length === 0) { - throw new TypeError('notify requires non-empty text'); - } - if (typeof message.call_id !== 'string' || message.call_id.length === 0) { - throw new TypeError('notify requires a valid call id'); - } - await protocol.send({ - type: 'notify', - cell_id: session.id, - call_id: message.call_id, - text: message.text, - }); - return; - } - - if (message.type === 'tool_call') { - void forwardToolCall(protocol, session, message); - return; - } - - if (message.type === 'result') { - const result = { - type: 'result', - stored_values: cloneJsonValue(message.stored_values ?? {}), - error_text: - typeof message.error_text === 'string' ? message.error_text : undefined, - }; - if (session.request_id === null) { - session.pending_result = result; - session.initial_yield_timer = clearTimer(session.initial_yield_timer); - session.poll_yield_timer = clearTimer(session.poll_yield_timer); - return; - } - await completeSession(protocol, sessions, session, result); - return; - } - - process.stderr.write('Unknown worker message type: ' + message.type + '\n'); -} - -async function forwardToolCall(protocol, session, message) { - try { - const result = await protocol.request('tool_call', { - name: String(message.name), - input: message.input, - }); - if (session.completed) { - return; - } - try { - session.worker.postMessage({ - type: 'tool_response', - id: message.id, - result, - }); - } catch {} - } catch (error) { - if (session.completed) { - return; - } - try { - session.worker.postMessage({ - type: 'tool_response_error', - id: message.id, - error_text: formatErrorText(error), - }); - } catch {} - } -} - -async function sendYielded(protocol, session) { - if (session.completed || session.request_id === null) { - return; - } - session.initial_yield_timer = clearTimer(session.initial_yield_timer); - session.initial_yield_triggered = true; - session.poll_yield_timer = clearTimer(session.poll_yield_timer); - const contentItems = takeContentItems(session); - const requestId = session.request_id; - try { - session.worker.postMessage({ type: 'clear_content' }); - } catch {} - await protocol.send({ - type: 'yielded', - request_id: requestId, - content_items: contentItems, - }); - session.request_id = null; -} - -function scheduleInitialYield(protocol, session, yieldTime) { - if (session.completed || session.initial_yield_triggered) { - return yieldTime; - } - session.initial_yield_timer = clearTimer(session.initial_yield_timer); - session.initial_yield_timer = setTimeout(() => { - session.initial_yield_timer = null; - session.initial_yield_triggered = true; - void sendYielded(protocol, session); - }, yieldTime); - return yieldTime; -} - -function schedulePollYield(protocol, session, yieldTime) { - if (session.completed) { - return; - } - session.poll_yield_timer = clearTimer(session.poll_yield_timer); - session.poll_yield_timer = setTimeout(() => { - session.poll_yield_timer = null; - void sendYielded(protocol, session); - }, yieldTime); -} - -async function completeSession(protocol, sessions, session, message) { - if (session.completed) { - return; - } - if (session.request_id === null) { - session.pending_result = message; - session.initial_yield_timer = clearTimer(session.initial_yield_timer); - session.poll_yield_timer = clearTimer(session.poll_yield_timer); - return; - } - const requestId = session.request_id; - session.completed = true; - session.initial_yield_timer = clearTimer(session.initial_yield_timer); - session.poll_yield_timer = clearTimer(session.poll_yield_timer); - sessions.delete(session.id); - const contentItems = takeContentItems(session); - session.pending_result = null; - try { - session.worker.postMessage({ type: 'clear_content' }); - } catch {} - await protocol.send({ - ...message, - request_id: requestId, - content_items: contentItems, - max_output_tokens_per_exec_call: session.max_output_tokens_per_exec_call, - }); -} - -async function terminateSession(protocol, sessions, session) { - if (session.completed) { - return; - } - session.completed = true; - session.initial_yield_timer = clearTimer(session.initial_yield_timer); - session.poll_yield_timer = clearTimer(session.poll_yield_timer); - sessions.delete(session.id); - const contentItems = takeContentItems(session); - try { - await session.worker.terminate(); - } catch {} - await protocol.send({ - type: 'terminated', - request_id: session.request_id, - content_items: contentItems, - }); -} - -async function main() { - const protocol = createProtocol(); - await protocol.closed; -} - -void main().catch(async (error) => { - try { - process.stderr.write(formatErrorText(error) + '\n'); - } finally { - process.exitCode = 1; - } -}); diff --git a/codex-rs/core/src/tools/code_mode/service.rs b/codex-rs/core/src/tools/code_mode/service.rs deleted file mode 100644 index a9fadedb82..0000000000 --- a/codex-rs/core/src/tools/code_mode/service.rs +++ /dev/null @@ -1,108 +0,0 @@ -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::Arc; - -use serde_json::Value as JsonValue; -use tokio::sync::Mutex; -use tracing::warn; - -use crate::codex::Session; -use crate::codex::TurnContext; -use crate::tools::ToolRouter; -use crate::tools::context::SharedTurnDiffTracker; -use crate::tools::js_repl::resolve_compatible_node; -use crate::tools::parallel::ToolCallRuntime; -use codex_features::Feature; - -use super::ExecContext; -use super::PUBLIC_TOOL_NAME; -use super::process::CodeModeProcess; -use super::process::spawn_code_mode_process; -use super::worker::CodeModeWorker; - -pub(crate) struct CodeModeService { - js_repl_node_path: Option, - stored_values: Mutex>, - process: Arc>>, - next_cell_id: Mutex, -} - -impl CodeModeService { - pub(crate) fn new(js_repl_node_path: Option) -> Self { - Self { - js_repl_node_path, - stored_values: Mutex::new(HashMap::new()), - process: Arc::new(Mutex::new(None)), - next_cell_id: Mutex::new(1), - } - } - - pub(crate) async fn stored_values(&self) -> HashMap { - self.stored_values.lock().await.clone() - } - - pub(crate) async fn replace_stored_values(&self, values: HashMap) { - *self.stored_values.lock().await = values; - } - - pub(super) async fn ensure_started( - &self, - ) -> Result>, std::io::Error> { - let mut process_slot = self.process.lock().await; - let needs_spawn = match process_slot.as_mut() { - Some(process) => !matches!(process.has_exited(), Ok(false)), - None => true, - }; - if needs_spawn { - let node_path = resolve_compatible_node(self.js_repl_node_path.as_deref()) - .await - .map_err(std::io::Error::other)?; - *process_slot = Some(spawn_code_mode_process(&node_path).await?); - } - drop(process_slot); - Ok(self.process.clone().lock_owned().await) - } - - pub(crate) async fn start_turn_worker( - &self, - session: &Arc, - turn: &Arc, - router: Arc, - tracker: SharedTurnDiffTracker, - ) -> Option { - if !turn.features.enabled(Feature::CodeMode) { - return None; - } - let exec = ExecContext { - session: Arc::clone(session), - turn: Arc::clone(turn), - }; - let tool_runtime = - ToolCallRuntime::new(router, Arc::clone(session), Arc::clone(turn), tracker); - let mut process_slot = match self.ensure_started().await { - Ok(process_slot) => process_slot, - Err(err) => { - warn!("failed to start {PUBLIC_TOOL_NAME} worker for turn: {err}"); - return None; - } - }; - let Some(process) = process_slot.as_mut() else { - warn!( - "failed to start {PUBLIC_TOOL_NAME} worker for turn: {PUBLIC_TOOL_NAME} runner failed to start" - ); - return None; - }; - Some(process.worker(exec, tool_runtime)) - } - - pub(crate) async fn allocate_cell_id(&self) -> String { - let mut next_cell_id = self.next_cell_id.lock().await; - let cell_id = *next_cell_id; - *next_cell_id = next_cell_id.saturating_add(1); - cell_id.to_string() - } - - pub(crate) async fn allocate_request_id(&self) -> String { - uuid::Uuid::new_v4().to_string() - } -} diff --git a/codex-rs/core/src/tools/code_mode/wait_description.md b/codex-rs/core/src/tools/code_mode/wait_description.md deleted file mode 100644 index 41b928f514..0000000000 --- a/codex-rs/core/src/tools/code_mode/wait_description.md +++ /dev/null @@ -1,8 +0,0 @@ -- Use `wait` only after `exec` returns `Script running with cell ID ...`. -- `cell_id` identifies the running `exec` cell to resume. -- `yield_time_ms` controls how long to wait for more output before yielding again. If omitted, `wait` uses its default wait timeout. -- `max_tokens` limits how much new output this wait call returns. -- `terminate: true` stops the running cell instead of waiting for more output. -- `wait` returns only the new output since the last yield, or the final completion or termination result for that cell. -- If the cell is still running, `wait` may yield again with the same `cell_id`. -- If the cell has already finished, `wait` returns the completed result and closes the cell. diff --git a/codex-rs/core/src/tools/code_mode/wait_handler.rs b/codex-rs/core/src/tools/code_mode/wait_handler.rs index caaf8c8c44..f319985a88 100644 --- a/codex-rs/core/src/tools/code_mode/wait_handler.rs +++ b/codex-rs/core/src/tools/code_mode/wait_handler.rs @@ -8,13 +8,10 @@ use crate::tools::context::ToolPayload; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; -use super::CodeModeSessionProgress; use super::DEFAULT_WAIT_YIELD_TIME_MS; use super::ExecContext; -use super::PUBLIC_TOOL_NAME; use super::WAIT_TOOL_NAME; -use super::handle_node_message; -use super::protocol::HostToNodeMessage; +use super::handle_runtime_response; pub struct CodeModeWaitHandler; @@ -63,66 +60,21 @@ impl ToolHandler for CodeModeWaitHandler { ToolPayload::Function { arguments } if tool_name == WAIT_TOOL_NAME => { let args: ExecWaitArgs = parse_arguments(&arguments)?; let exec = ExecContext { session, turn }; - let request_id = exec - .session - .services - .code_mode_service - .allocate_request_id() - .await; let started_at = std::time::Instant::now(); - let message = if args.terminate { - HostToNodeMessage::Terminate { - request_id: request_id.clone(), - cell_id: args.cell_id.clone(), - } - } else { - HostToNodeMessage::Poll { - request_id: request_id.clone(), - cell_id: args.cell_id.clone(), - yield_time_ms: args.yield_time_ms, - } - }; - let process_slot = exec + let response = exec .session .services .code_mode_service - .ensure_started() + .wait(codex_code_mode::WaitRequest { + cell_id: args.cell_id, + yield_time_ms: args.yield_time_ms, + terminate: args.terminate, + }) .await - .map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?; - let result = { - let mut process_slot = process_slot; - let Some(process) = process_slot.as_mut() else { - return Err(FunctionCallError::RespondToModel(format!( - "{PUBLIC_TOOL_NAME} runner failed to start" - ))); - }; - if !matches!(process.has_exited(), Ok(false)) { - return Err(FunctionCallError::RespondToModel(format!( - "{PUBLIC_TOOL_NAME} runner failed to start" - ))); - } - let message = process - .send(&request_id, &message) - .await - .map_err(|err| err.to_string()); - let message = match message { - Ok(message) => message, - Err(error) => return Err(FunctionCallError::RespondToModel(error)), - }; - handle_node_message( - &exec, - args.cell_id, - message, - Some(args.max_tokens), - started_at, - ) + .map_err(FunctionCallError::RespondToModel)?; + handle_runtime_response(&exec, response, args.max_tokens, started_at) .await - }; - match result { - Ok(CodeModeSessionProgress::Finished(output)) - | Ok(CodeModeSessionProgress::Yielded { output }) => Ok(output), - Err(error) => Err(FunctionCallError::RespondToModel(error)), - } + .map_err(FunctionCallError::RespondToModel) } _ => Err(FunctionCallError::RespondToModel(format!( "{WAIT_TOOL_NAME} expects JSON arguments" diff --git a/codex-rs/core/src/tools/code_mode/worker.rs b/codex-rs/core/src/tools/code_mode/worker.rs deleted file mode 100644 index 5853f3abe3..0000000000 --- a/codex-rs/core/src/tools/code_mode/worker.rs +++ /dev/null @@ -1,116 +0,0 @@ -use tokio::sync::oneshot; -use tokio_util::sync::CancellationToken; -use tracing::error; -use tracing::warn; - -use codex_protocol::models::FunctionCallOutputPayload; -use codex_protocol::models::ResponseInputItem; - -use super::ExecContext; -use super::PUBLIC_TOOL_NAME; -use super::call_nested_tool; -use super::process::CodeModeProcess; -use super::process::write_message; -use super::protocol::HostToNodeMessage; -use super::protocol::NodeToHostMessage; -use crate::tools::parallel::ToolCallRuntime; - -pub(crate) struct CodeModeWorker { - shutdown_tx: Option>, -} - -impl Drop for CodeModeWorker { - fn drop(&mut self) { - if let Some(shutdown_tx) = self.shutdown_tx.take() { - let _ = shutdown_tx.send(()); - } - } -} - -impl CodeModeProcess { - pub(super) fn worker( - &self, - exec: ExecContext, - tool_runtime: ToolCallRuntime, - ) -> CodeModeWorker { - let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); - let stdin = self.stdin.clone(); - let message_rx = self.message_rx.clone(); - tokio::spawn(async move { - loop { - let next_message = tokio::select! { - _ = &mut shutdown_rx => break, - message = async { - let mut message_rx = message_rx.lock().await; - message_rx.recv().await - } => message, - }; - let Some(next_message) = next_message else { - break; - }; - match next_message { - NodeToHostMessage::ToolCall { tool_call } => { - let exec = exec.clone(); - let tool_runtime = tool_runtime.clone(); - let stdin = stdin.clone(); - tokio::spawn(async move { - let result = call_nested_tool( - exec, - tool_runtime, - tool_call.name, - tool_call.input, - CancellationToken::new(), - ) - .await; - let (code_mode_result, error_text) = match result { - Ok(code_mode_result) => (code_mode_result, None), - Err(error) => (serde_json::Value::Null, Some(error.to_string())), - }; - let response = HostToNodeMessage::Response { - request_id: tool_call.request_id, - id: tool_call.id, - code_mode_result, - error_text, - }; - if let Err(err) = write_message(&stdin, &response).await { - warn!("failed to write {PUBLIC_TOOL_NAME} tool response: {err}"); - } - }); - } - NodeToHostMessage::Notify { notify } => { - if notify.text.trim().is_empty() { - continue; - } - if exec - .session - .inject_response_items(vec![ResponseInputItem::CustomToolCallOutput { - call_id: notify.call_id.clone(), - name: Some(PUBLIC_TOOL_NAME.to_string()), - output: FunctionCallOutputPayload::from_text(notify.text), - }]) - .await - .is_err() - { - warn!( - "failed to inject {PUBLIC_TOOL_NAME} notify message for cell {}: no active turn", - notify.cell_id - ); - } - } - unexpected_message @ (NodeToHostMessage::Yielded { .. } - | NodeToHostMessage::Terminated { .. } - | NodeToHostMessage::Result { .. }) => { - error!( - "received unexpected {PUBLIC_TOOL_NAME} message in worker loop: {unexpected_message:?}" - ); - break; - } - } - } - }); - - CodeModeWorker { - shutdown_tx: Some(shutdown_tx), - } - } -} diff --git a/codex-rs/core/src/tools/code_mode_description.rs b/codex-rs/core/src/tools/code_mode_description.rs index b7722aeb72..fb4fc1f519 100644 --- a/codex-rs/core/src/tools/code_mode_description.rs +++ b/codex-rs/core/src/tools/code_mode_description.rs @@ -1,30 +1,11 @@ use crate::client_common::tools::ToolSpec; -use crate::mcp::split_qualified_tool_name; -use crate::tools::code_mode::PUBLIC_TOOL_NAME; -use serde_json::Value as JsonValue; -pub(crate) struct CodeModeToolReference { - pub(crate) module_path: String, - pub(crate) namespace: Vec, - pub(crate) tool_key: String, -} - -pub(crate) fn code_mode_tool_reference(tool_name: &str) -> CodeModeToolReference { - if let Some((server_name, tool_key)) = split_qualified_tool_name(tool_name) { - let namespace = vec!["mcp".to_string(), server_name]; - return CodeModeToolReference { - module_path: format!("tools/{}.js", namespace.join("/")), - namespace, - tool_key, - }; - } - - CodeModeToolReference { - module_path: "tools.js".to_string(), - namespace: Vec::new(), - tool_key: tool_name.to_string(), - } -} +#[allow(unused_imports)] +#[cfg(test)] +pub(crate) use codex_code_mode::append_code_mode_sample; +#[allow(unused_imports)] +#[cfg(test)] +pub(crate) use codex_code_mode::render_json_schema_to_typescript; pub(crate) fn augment_tool_spec_for_code_mode(spec: ToolSpec, code_mode_enabled: bool) -> ToolSpec { if !code_mode_enabled { @@ -33,27 +14,27 @@ pub(crate) fn augment_tool_spec_for_code_mode(spec: ToolSpec, code_mode_enabled: match spec { ToolSpec::Function(mut tool) => { - if tool.name != PUBLIC_TOOL_NAME { - tool.description = append_code_mode_sample( - &tool.description, - &tool.name, - "args", - serde_json::to_value(&tool.parameters) - .ok() - .as_ref() - .map(render_json_schema_to_typescript) - .unwrap_or_else(|| "unknown".to_string()), - tool.output_schema - .as_ref() - .map(render_json_schema_to_typescript) - .unwrap_or_else(|| "unknown".to_string()), - ); - } + let input_type = serde_json::to_value(&tool.parameters) + .ok() + .map(|schema| codex_code_mode::render_json_schema_to_typescript(&schema)) + .unwrap_or_else(|| "unknown".to_string()); + let output_type = tool + .output_schema + .as_ref() + .map(codex_code_mode::render_json_schema_to_typescript) + .unwrap_or_else(|| "unknown".to_string()); + tool.description = codex_code_mode::append_code_mode_sample( + &tool.description, + &tool.name, + "args", + input_type, + output_type, + ); ToolSpec::Function(tool) } ToolSpec::Freeform(mut tool) => { - if tool.name != PUBLIC_TOOL_NAME { - tool.description = append_code_mode_sample( + if tool.name != codex_code_mode::PUBLIC_TOOL_NAME { + tool.description = codex_code_mode::append_code_mode_sample( &tool.description, &tool.name, "input", @@ -66,234 +47,3 @@ pub(crate) fn augment_tool_spec_for_code_mode(spec: ToolSpec, code_mode_enabled: other => other, } } - -fn append_code_mode_sample( - description: &str, - tool_name: &str, - input_name: &str, - input_type: String, - output_type: String, -) -> String { - let declaration = format!( - "declare const tools: {{ {} }};", - render_code_mode_tool_declaration(tool_name, input_name, input_type, output_type) - ); - format!("{description}\n\nexec tool declaration:\n```ts\n{declaration}\n```") -} - -fn render_code_mode_tool_declaration( - tool_name: &str, - input_name: &str, - input_type: String, - output_type: String, -) -> String { - let tool_name = normalize_code_mode_identifier(tool_name); - format!("{tool_name}({input_name}: {input_type}): Promise<{output_type}>;") -} - -pub(crate) fn normalize_code_mode_identifier(tool_key: &str) -> String { - let mut identifier = String::new(); - - for (index, ch) in tool_key.chars().enumerate() { - let is_valid = if index == 0 { - ch == '_' || ch == '$' || ch.is_ascii_alphabetic() - } else { - ch == '_' || ch == '$' || ch.is_ascii_alphanumeric() - }; - - if is_valid { - identifier.push(ch); - } else { - identifier.push('_'); - } - } - - if identifier.is_empty() { - "_".to_string() - } else { - identifier - } -} - -fn render_json_schema_to_typescript(schema: &JsonValue) -> String { - render_json_schema_to_typescript_inner(schema) -} - -fn render_json_schema_to_typescript_inner(schema: &JsonValue) -> String { - match schema { - JsonValue::Bool(true) => "unknown".to_string(), - JsonValue::Bool(false) => "never".to_string(), - JsonValue::Object(map) => { - if let Some(value) = map.get("const") { - return render_json_schema_literal(value); - } - - if let Some(values) = map.get("enum").and_then(serde_json::Value::as_array) { - let rendered = values - .iter() - .map(render_json_schema_literal) - .collect::>(); - if !rendered.is_empty() { - return rendered.join(" | "); - } - } - - for key in ["anyOf", "oneOf"] { - if let Some(variants) = map.get(key).and_then(serde_json::Value::as_array) { - let rendered = variants - .iter() - .map(render_json_schema_to_typescript_inner) - .collect::>(); - if !rendered.is_empty() { - return rendered.join(" | "); - } - } - } - - if let Some(variants) = map.get("allOf").and_then(serde_json::Value::as_array) { - let rendered = variants - .iter() - .map(render_json_schema_to_typescript_inner) - .collect::>(); - if !rendered.is_empty() { - return rendered.join(" & "); - } - } - - if let Some(schema_type) = map.get("type") { - if let Some(types) = schema_type.as_array() { - let rendered = types - .iter() - .filter_map(serde_json::Value::as_str) - .map(|schema_type| render_json_schema_type_keyword(map, schema_type)) - .collect::>(); - if !rendered.is_empty() { - return rendered.join(" | "); - } - } - - if let Some(schema_type) = schema_type.as_str() { - return render_json_schema_type_keyword(map, schema_type); - } - } - - if map.contains_key("properties") - || map.contains_key("additionalProperties") - || map.contains_key("required") - { - return render_json_schema_object(map); - } - - if map.contains_key("items") || map.contains_key("prefixItems") { - return render_json_schema_array(map); - } - - "unknown".to_string() - } - _ => "unknown".to_string(), - } -} - -fn render_json_schema_type_keyword( - map: &serde_json::Map, - schema_type: &str, -) -> String { - match schema_type { - "string" => "string".to_string(), - "number" | "integer" => "number".to_string(), - "boolean" => "boolean".to_string(), - "null" => "null".to_string(), - "array" => render_json_schema_array(map), - "object" => render_json_schema_object(map), - _ => "unknown".to_string(), - } -} - -fn render_json_schema_array(map: &serde_json::Map) -> String { - if let Some(items) = map.get("items") { - let item_type = render_json_schema_to_typescript_inner(items); - return format!("Array<{item_type}>"); - } - - if let Some(items) = map.get("prefixItems").and_then(serde_json::Value::as_array) { - let item_types = items - .iter() - .map(render_json_schema_to_typescript_inner) - .collect::>(); - if !item_types.is_empty() { - return format!("[{}]", item_types.join(", ")); - } - } - - "unknown[]".to_string() -} - -fn render_json_schema_object(map: &serde_json::Map) -> String { - let required = map - .get("required") - .and_then(serde_json::Value::as_array) - .map(|items| { - items - .iter() - .filter_map(serde_json::Value::as_str) - .collect::>() - }) - .unwrap_or_default(); - let properties = map - .get("properties") - .and_then(serde_json::Value::as_object) - .cloned() - .unwrap_or_default(); - - let mut sorted_properties = properties.iter().collect::>(); - sorted_properties.sort_unstable_by(|(name_a, _), (name_b, _)| name_a.cmp(name_b)); - let mut lines = sorted_properties - .into_iter() - .map(|(name, value)| { - let optional = if required.iter().any(|required_name| required_name == name) { - "" - } else { - "?" - }; - let property_name = render_json_schema_property_name(name); - let property_type = render_json_schema_to_typescript_inner(value); - format!("{property_name}{optional}: {property_type};") - }) - .collect::>(); - - if let Some(additional_properties) = map.get("additionalProperties") { - let additional_type = match additional_properties { - JsonValue::Bool(true) => Some("unknown".to_string()), - JsonValue::Bool(false) => None, - value => Some(render_json_schema_to_typescript_inner(value)), - }; - - if let Some(additional_type) = additional_type { - lines.push(format!("[key: string]: {additional_type};")); - } - } else if properties.is_empty() { - lines.push("[key: string]: unknown;".to_string()); - } - - if lines.is_empty() { - return "{}".to_string(); - } - - format!("{{ {} }}", lines.join(" ")) -} - -fn render_json_schema_property_name(name: &str) -> String { - if normalize_code_mode_identifier(name) == name { - name.to_string() - } else { - serde_json::to_string(name).unwrap_or_else(|_| format!("\"{}\"", name.replace('"', "\\\""))) - } -} - -fn render_json_schema_literal(value: &JsonValue) -> String { - serde_json::to_string(value).unwrap_or_else(|_| "unknown".to_string()) -} - -#[cfg(test)] -#[path = "code_mode_description_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index 8fa990a3b9..897af0d5f0 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -6,6 +6,8 @@ //! then optionally layer role-specific config on top. use crate::agent::AgentStatus; +use crate::agent::agent_resolver::resolve_agent_target; +use crate::agent::agent_resolver::resolve_agent_targets; use crate::agent::exceeds_thread_spawn_depth_limit; use crate::codex::Session; use crate::codex::TurnContext; @@ -22,6 +24,7 @@ use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; use async_trait::async_trait; use codex_features::Feature; +use codex_protocol::AgentPath; use codex_protocol::ThreadId; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ResponseInputItem; @@ -59,11 +62,6 @@ pub(crate) const MIN_WAIT_TIMEOUT_MS: i64 = 10_000; pub(crate) const DEFAULT_WAIT_TIMEOUT_MS: i64 = 30_000; pub(crate) const MAX_WAIT_TIMEOUT_MS: i64 = 3600 * 1000; -#[derive(Debug, Deserialize)] -struct CloseAgentArgs { - id: String, -} - fn function_arguments(payload: ToolPayload) -> Result { match payload { ToolPayload::Function { arguments } => Ok(arguments), @@ -111,11 +109,6 @@ mod send_input; mod spawn; pub(crate) mod wait; -fn agent_id(id: &str) -> Result { - ThreadId::from_string(id) - .map_err(|e| FunctionCallError::RespondToModel(format!("invalid agent id {id}: {e:?}"))) -} - fn build_wait_agent_statuses( statuses: &HashMap, receiver_agents: &[CollabAgentRef], @@ -155,9 +148,10 @@ fn build_wait_agent_statuses( fn collab_spawn_error(err: CodexErr) -> FunctionCallError { match err { - CodexErr::UnsupportedOperation(_) => { + CodexErr::UnsupportedOperation(message) if message == "thread manager dropped" => { FunctionCallError::RespondToModel("collab manager unavailable".to_string()) } + CodexErr::UnsupportedOperation(message) => FunctionCallError::RespondToModel(message), err => FunctionCallError::RespondToModel(format!("collab spawn failed: {err}")), } } @@ -179,15 +173,28 @@ fn collab_agent_error(agent_id: ThreadId, err: CodexErr) -> FunctionCallError { fn thread_spawn_source( parent_thread_id: ThreadId, + parent_session_source: &SessionSource, depth: i32, agent_role: Option<&str>, -) -> SessionSource { - SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + task_name: Option, +) -> Result { + let agent_path = task_name + .as_deref() + .map(|task_name| { + parent_session_source + .get_agent_path() + .unwrap_or_else(AgentPath::root) + .join(task_name) + .map_err(FunctionCallError::RespondToModel) + }) + .transpose()?; + Ok(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth, + agent_path, agent_nickname: None, agent_role: agent_role.map(str::to_string), - }) + })) } fn parse_collab_input( diff --git a/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs index f65fdd6441..022faa7b76 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs @@ -24,13 +24,12 @@ impl ToolHandler for Handler { } = invocation; let arguments = function_arguments(payload)?; let args: CloseAgentArgs = parse_arguments(&arguments)?; - let agent_id = agent_id(&args.id)?; - let (receiver_agent_nickname, receiver_agent_role) = session + let agent_id = resolve_agent_target(&session, &turn, &args.target).await?; + let receiver_agent = session .services .agent_control - .get_agent_nickname_and_role(agent_id) - .await - .unwrap_or((None, None)); + .get_agent_metadata(agent_id) + .unwrap_or_default(); session .send_event( &turn, @@ -58,8 +57,8 @@ impl ToolHandler for Handler { call_id: call_id.clone(), sender_thread_id: session.conversation_id, receiver_thread_id: agent_id, - receiver_agent_nickname: receiver_agent_nickname.clone(), - receiver_agent_role: receiver_agent_role.clone(), + receiver_agent_nickname: receiver_agent.agent_nickname.clone(), + receiver_agent_role: receiver_agent.agent_role.clone(), status, } .into(), @@ -82,8 +81,8 @@ impl ToolHandler for Handler { call_id, sender_thread_id: session.conversation_id, receiver_thread_id: agent_id, - receiver_agent_nickname, - receiver_agent_role, + receiver_agent_nickname: receiver_agent.agent_nickname, + receiver_agent_role: receiver_agent.agent_role, status: status.clone(), } .into(), @@ -119,3 +118,8 @@ impl ToolOutput for CloseAgentResult { tool_output_code_mode_result(self, "close_agent") } } + +#[derive(Debug, Deserialize)] +struct CloseAgentArgs { + target: String, +} diff --git a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs index f8a339cc61..85e879c1bb 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs @@ -25,13 +25,14 @@ impl ToolHandler for Handler { } = invocation; let arguments = function_arguments(payload)?; let args: ResumeAgentArgs = parse_arguments(&arguments)?; - let receiver_thread_id = agent_id(&args.id)?; - let (receiver_agent_nickname, receiver_agent_role) = session + let receiver_thread_id = ThreadId::from_string(&args.id).map_err(|err| { + FunctionCallError::RespondToModel(format!("invalid agent id {}: {err:?}", args.id)) + })?; + let receiver_agent = session .services .agent_control - .get_agent_nickname_and_role(receiver_thread_id) - .await - .unwrap_or((None, None)); + .get_agent_metadata(receiver_thread_id) + .unwrap_or_default(); let child_depth = next_thread_spawn_depth(&turn.session_source); let max_depth = turn.config.agent_max_depth; if exceeds_thread_spawn_depth_limit(child_depth, max_depth) { @@ -47,8 +48,8 @@ impl ToolHandler for Handler { call_id: call_id.clone(), sender_thread_id: session.conversation_id, receiver_thread_id, - receiver_agent_nickname: receiver_agent_nickname.clone(), - receiver_agent_role: receiver_agent_role.clone(), + receiver_agent_nickname: receiver_agent.agent_nickname.clone(), + receiver_agent_role: receiver_agent.agent_role.clone(), } .into(), ) @@ -59,11 +60,22 @@ impl ToolHandler for Handler { .agent_control .get_status(receiver_thread_id) .await; - let error = if matches!(status, AgentStatus::NotFound) { + let (receiver_agent, error) = if matches!(status, AgentStatus::NotFound) { match try_resume_closed_agent(&session, &turn, receiver_thread_id, child_depth).await { - Ok(resumed_status) => { - status = resumed_status; - None + Ok(()) => { + status = session + .services + .agent_control + .get_status(receiver_thread_id) + .await; + ( + session + .services + .agent_control + .get_agent_metadata(receiver_thread_id) + .unwrap_or(receiver_agent), + None, + ) } Err(err) => { status = session @@ -71,19 +83,12 @@ impl ToolHandler for Handler { .agent_control .get_status(receiver_thread_id) .await; - Some(err) + (receiver_agent, Some(err)) } } } else { - None + (receiver_agent, None) }; - - let (receiver_agent_nickname, receiver_agent_role) = session - .services - .agent_control - .get_agent_nickname_and_role(receiver_thread_id) - .await - .unwrap_or((receiver_agent_nickname, receiver_agent_role)); session .send_event( &turn, @@ -91,8 +96,8 @@ impl ToolHandler for Handler { call_id, sender_thread_id: session.conversation_id, receiver_thread_id, - receiver_agent_nickname, - receiver_agent_role, + receiver_agent_nickname: receiver_agent.agent_nickname, + receiver_agent_role: receiver_agent.agent_role, status: status.clone(), } .into(), @@ -142,9 +147,9 @@ async fn try_resume_closed_agent( turn: &Arc, receiver_thread_id: ThreadId, child_depth: i32, -) -> Result { +) -> Result<(), FunctionCallError> { let config = build_agent_resume_config(turn.as_ref(), child_depth)?; - let resumed_thread_id = session + session .services .agent_control .resume_agent_from_rollout( @@ -152,16 +157,13 @@ async fn try_resume_closed_agent( receiver_thread_id, thread_spawn_source( session.conversation_id, + &turn.session_source, child_depth, /*agent_role*/ None, - ), + /*task_name*/ None, + )?, ) .await - .map_err(|err| collab_agent_error(receiver_thread_id, err))?; - - Ok(session - .services - .agent_control - .get_status(resumed_thread_id) - .await) + .map(|_| ()) + .map_err(|err| collab_agent_error(receiver_thread_id, err)) } diff --git a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs index 0b6b06f21b..25f70c7305 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs @@ -1,7 +1,15 @@ use super::*; +use crate::agent::inter_agent_instruction::InterAgentDelivery; +use crate::agent::inter_agent_instruction::InterAgentInstruction; pub(crate) struct Handler; +fn can_use_v2_inter_agent_instruction(items: &[UserInput]) -> bool { + items + .iter() + .all(|item| matches!(item, UserInput::Text { .. })) +} + #[async_trait] impl ToolHandler for Handler { type Output = SendInputResult; @@ -24,15 +32,14 @@ impl ToolHandler for Handler { } = invocation; let arguments = function_arguments(payload)?; let args: SendInputArgs = parse_arguments(&arguments)?; - let receiver_thread_id = agent_id(&args.id)?; + let receiver_thread_id = resolve_agent_target(&session, &turn, &args.target).await?; let input_items = parse_collab_input(args.message, args.items)?; let prompt = input_preview(&input_items); - let (receiver_agent_nickname, receiver_agent_role) = session + let receiver_agent = session .services .agent_control - .get_agent_nickname_and_role(receiver_thread_id) - .await - .unwrap_or((None, None)); + .get_agent_metadata(receiver_thread_id) + .unwrap_or_default(); if args.interrupt { session .services @@ -53,12 +60,40 @@ impl ToolHandler for Handler { .into(), ) .await; - let result = session - .services - .agent_control - .send_input(receiver_thread_id, input_items) - .await - .map_err(|err| collab_agent_error(receiver_thread_id, err)); + let agent_control = session.services.agent_control.clone(); + let result = if turn.config.features.enabled(Feature::MultiAgentV2) + && can_use_v2_inter_agent_instruction(&input_items) + { + let receiver_agent_path = receiver_agent.agent_path.clone().ok_or_else(|| { + FunctionCallError::RespondToModel( + "target agent is missing an agent_path".to_string(), + ) + })?; + let instruction = InterAgentInstruction::new( + turn.session_source + .get_agent_path() + .unwrap_or_else(AgentPath::root), + receiver_agent_path, + Vec::new(), + prompt.clone(), + ); + agent_control + .deliver_inter_agent_instruction( + receiver_thread_id, + instruction, + if args.interrupt { + InterAgentDelivery::NextTurn + } else { + InterAgentDelivery::CurrentTurn + }, + ) + .await + } else { + agent_control + .send_input(receiver_thread_id, input_items) + .await + } + .map_err(|err| collab_agent_error(receiver_thread_id, err)); let status = session .services .agent_control @@ -71,8 +106,8 @@ impl ToolHandler for Handler { call_id, sender_thread_id: session.conversation_id, receiver_thread_id, - receiver_agent_nickname, - receiver_agent_role, + receiver_agent_nickname: receiver_agent.agent_nickname, + receiver_agent_role: receiver_agent.agent_role, prompt, status, } @@ -87,7 +122,7 @@ impl ToolHandler for Handler { #[derive(Debug, Deserialize)] struct SendInputArgs { - id: String, + target: String, message: Option, items: Option>, #[serde(default)] diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index 7a27cd94c8..53ab4d35fd 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -77,26 +77,29 @@ impl ToolHandler for Handler { let result = session .services .agent_control - .spawn_agent_with_options( + .spawn_agent_with_metadata( config, input_items, Some(thread_spawn_source( session.conversation_id, + &turn.session_source, child_depth, role_name, - )), + args.task_name.clone(), + )?), SpawnAgentOptions { fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()), }, ) .await .map_err(collab_spawn_error); - let (new_thread_id, status) = match &result { - Ok(thread_id) => ( - Some(*thread_id), - session.services.agent_control.get_status(*thread_id).await, + let (new_thread_id, new_agent_metadata, status) = match &result { + Ok(spawned_agent) => ( + Some(spawned_agent.thread_id), + Some(spawned_agent.metadata.clone()), + spawned_agent.status.clone(), ), - Err(_) => (None, AgentStatus::NotFound), + Err(_) => (None, None, AgentStatus::NotFound), }; let agent_snapshot = match new_thread_id { Some(thread_id) => { @@ -108,19 +111,20 @@ impl ToolHandler for Handler { } None => None, }; - let (new_agent_nickname, new_agent_role) = match (&agent_snapshot, new_thread_id) { - (Some(snapshot), _) => ( - snapshot.session_source.get_nickname(), - snapshot.session_source.get_agent_role(), - ), - (None, Some(thread_id)) => session - .services - .agent_control - .get_agent_nickname_and_role(thread_id) - .await - .unwrap_or((None, None)), - (None, None) => (None, None), - }; + let (new_agent_path, new_agent_nickname, new_agent_role) = + match (&agent_snapshot, new_agent_metadata) { + (Some(snapshot), _) => ( + snapshot.session_source.get_agent_path().map(String::from), + snapshot.session_source.get_nickname(), + snapshot.session_source.get_agent_role(), + ), + (None, Some(metadata)) => ( + metadata.agent_path.map(String::from), + metadata.agent_nickname, + metadata.agent_role, + ), + (None, None) => (None, None, None), + }; let effective_model = agent_snapshot .as_ref() .map(|snapshot| snapshot.model.clone()) @@ -130,6 +134,7 @@ impl ToolHandler for Handler { .and_then(|snapshot| snapshot.reasoning_effort) .unwrap_or(args.reasoning_effort.unwrap_or_default()); let nickname = new_agent_nickname.clone(); + let task_name = new_agent_path.clone(); session .send_event( &turn, @@ -147,7 +152,7 @@ impl ToolHandler for Handler { .into(), ) .await; - let new_thread_id = result?; + let new_thread_id = result?.thread_id; let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME); turn.session_telemetry.counter( "codex.multi_agent.spawn", @@ -156,7 +161,8 @@ impl ToolHandler for Handler { ); Ok(SpawnAgentResult { - agent_id: new_thread_id.to_string(), + agent_id: task_name.is_none().then(|| new_thread_id.to_string()), + task_name, nickname, }) } @@ -166,6 +172,7 @@ impl ToolHandler for Handler { struct SpawnAgentArgs { message: Option, items: Option>, + task_name: Option, agent_type: Option, model: Option, reasoning_effort: Option, @@ -175,7 +182,8 @@ struct SpawnAgentArgs { #[derive(Debug, Serialize)] pub(crate) struct SpawnAgentResult { - agent_id: String, + agent_id: Option, + task_name: Option, nickname: Option, } diff --git a/codex-rs/core/src/tools/handlers/multi_agents/wait.rs b/codex-rs/core/src/tools/handlers/multi_agents/wait.rs index 2d655ce86d..8458402ce5 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/wait.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/wait.rs @@ -34,28 +34,27 @@ impl ToolHandler for Handler { } = invocation; let arguments = function_arguments(payload)?; let args: WaitArgs = parse_arguments(&arguments)?; - if args.ids.is_empty() { - return Err(FunctionCallError::RespondToModel( - "ids must be non-empty".to_owned(), - )); - } - let receiver_thread_ids = args - .ids - .iter() - .map(|id| agent_id(id)) - .collect::, _>>()?; + let receiver_thread_ids = resolve_agent_targets(&session, &turn, args.targets).await?; let mut receiver_agents = Vec::with_capacity(receiver_thread_ids.len()); + let mut target_by_thread_id = HashMap::with_capacity(receiver_thread_ids.len()); for receiver_thread_id in &receiver_thread_ids { - let (agent_nickname, agent_role) = session + let agent_metadata = session .services .agent_control - .get_agent_nickname_and_role(*receiver_thread_id) - .await - .unwrap_or((None, None)); + .get_agent_metadata(*receiver_thread_id) + .unwrap_or_default(); + target_by_thread_id.insert( + *receiver_thread_id, + agent_metadata + .agent_path + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| receiver_thread_id.to_string()), + ); receiver_agents.push(CollabAgentRef { thread_id: *receiver_thread_id, - agent_nickname, - agent_role, + agent_nickname: agent_metadata.agent_nickname, + agent_role: agent_metadata.agent_role, }); } @@ -151,11 +150,20 @@ impl ToolHandler for Handler { results }; - let statuses_map = statuses.clone().into_iter().collect::>(); - let agent_statuses = build_wait_agent_statuses(&statuses_map, &receiver_agents); + let timed_out = statuses.is_empty(); + let statuses_by_id = statuses.clone().into_iter().collect::>(); + let agent_statuses = build_wait_agent_statuses(&statuses_by_id, &receiver_agents); let result = WaitAgentResult { - status: statuses_map.clone(), - timed_out: statuses.is_empty(), + status: statuses + .into_iter() + .filter_map(|(thread_id, status)| { + target_by_thread_id + .get(&thread_id) + .cloned() + .map(|target| (target, status)) + }) + .collect(), + timed_out, }; session @@ -165,7 +173,7 @@ impl ToolHandler for Handler { sender_thread_id: session.conversation_id, call_id, agent_statuses, - statuses: statuses_map, + statuses: statuses_by_id, } .into(), ) @@ -177,13 +185,14 @@ impl ToolHandler for Handler { #[derive(Debug, Deserialize)] struct WaitArgs { - ids: Vec, + #[serde(default)] + targets: Vec, timeout_ms: Option, } #[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] pub(crate) struct WaitAgentResult { - pub(crate) status: HashMap, + pub(crate) status: HashMap, pub(crate) timed_out: bool, } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 9dbcd7b9c1..aa99e9211b 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -7,6 +7,7 @@ use crate::codex::make_session_and_context; use crate::config::DEFAULT_AGENT_MAX_DEPTH; use crate::config::types::ShellEnvironmentPolicy; use crate::function_tool::FunctionCallError; +use crate::protocol::AgentStatus; use crate::protocol::AskForApproval; use crate::protocol::FileSystemSandboxPolicy; use crate::protocol::NetworkSandboxPolicy; @@ -14,6 +15,9 @@ use crate::protocol::Op; use crate::protocol::SandboxPolicy; use crate::protocol::SessionSource; use crate::protocol::SubAgentSource; +use crate::state::TaskKind; +use crate::tasks::SessionTask; +use crate::tasks::SessionTaskContext; use crate::tools::context::ToolOutput; use crate::turn_diff_tracker::TurnDiffTracker; use codex_features::Feature; @@ -24,6 +28,7 @@ use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::RolloutItem; +use codex_protocol::user_input::UserInput; use pretty_assertions::assert_eq; use serde::Deserialize; use serde_json::json; @@ -33,6 +38,7 @@ use std::sync::Arc; use std::time::Duration; use tokio::sync::Mutex; use tokio::time::timeout; +use tokio_util::sync::CancellationToken; fn invocation( session: Arc, @@ -57,6 +63,10 @@ fn function_payload(args: serde_json::Value) -> ToolPayload { } } +fn parse_agent_id(id: &str) -> ThreadId { + ThreadId::from_string(id).expect("agent id should be valid") +} + fn thread_manager() -> ThreadManager { ThreadManager::with_models_provider_for_tests( CodexAuth::from_api_key("dummy"), @@ -64,6 +74,31 @@ fn thread_manager() -> ThreadManager { ) } +#[derive(Clone, Copy)] +struct NeverEndingTask; + +#[async_trait::async_trait] +impl SessionTask for NeverEndingTask { + fn kind(&self) -> TaskKind { + TaskKind::Regular + } + + fn span_name(&self) -> &'static str { + "session_task.multi_agent_never_ending" + } + + async fn run( + self: Arc, + _session: Arc, + _ctx: Arc, + _input: Vec, + cancellation_token: CancellationToken, + ) -> Option { + cancellation_token.cancelled().await; + None + } +} + fn expect_text_output(output: T) -> (String, Option) where T: ToolOutput, @@ -195,7 +230,7 @@ async fn spawn_agent_uses_explorer_role_and_preserves_approval_policy() { let (content, _) = expect_text_output(output); let result: SpawnAgentResult = serde_json::from_str(&content).expect("spawn_agent result should be json"); - let agent_id = agent_id(&result.agent_id).expect("agent_id should be valid"); + let agent_id = parse_agent_id(&result.agent_id); assert!( result .nickname @@ -212,6 +247,33 @@ async fn spawn_agent_uses_explorer_role_and_preserves_approval_policy() { assert_eq!(snapshot.model_provider_id, "ollama"); } +#[tokio::test] +async fn spawn_agent_includes_task_name_key_when_not_named() { + let (mut session, turn) = make_session_and_context().await; + let manager = thread_manager(); + session.services.agent_control = manager.agent_control(); + + let output = SpawnAgentHandler + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo" + })), + )) + .await + .expect("spawn_agent should succeed"); + let (content, success) = expect_text_output(output); + let result: serde_json::Value = + serde_json::from_str(&content).expect("spawn_agent result should be json"); + + assert!(result["agent_id"].is_string()); + assert_eq!(result["task_name"], serde_json::Value::Null); + assert!(result.get("nickname").is_some()); + assert_eq!(success, Some(true)); +} + #[tokio::test] async fn spawn_agent_errors_when_manager_dropped() { let (session, turn) = make_session_and_context().await; @@ -230,6 +292,461 @@ async fn spawn_agent_errors_when_manager_dropped() { ); } +#[tokio::test] +async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path() { + #[derive(Debug, Deserialize)] + struct SpawnAgentResult { + task_name: String, + nickname: Option, + } + + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + turn.config = Arc::new(config); + + let session = Arc::new(session); + let turn = Arc::new(turn); + let spawn_output = SpawnAgentHandler + .handle(invocation( + session.clone(), + turn.clone(), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "task_name": "test_process" + })), + )) + .await + .expect("spawn_agent should succeed"); + let (content, _) = expect_text_output(spawn_output); + let spawn_result: SpawnAgentResult = + serde_json::from_str(&content).expect("spawn result should parse"); + assert_eq!(spawn_result.task_name, "/root/test_process"); + assert!(spawn_result.nickname.is_some()); + + let child_thread_id = session + .services + .agent_control + .resolve_agent_reference( + session.conversation_id, + &turn.session_source, + "test_process", + ) + .await + .expect("relative path should resolve"); + let child_snapshot = manager + .get_thread(child_thread_id) + .await + .expect("child thread should exist") + .config_snapshot() + .await; + assert_eq!( + child_snapshot.session_source.get_agent_path().as_deref(), + Some("/root/test_process") + ); + + SendInputHandler + .handle(invocation( + session.clone(), + turn.clone(), + "send_input", + function_payload(json!({ + "target": "test_process", + "message": "continue" + })), + )) + .await + .expect("send_input should accept v2 path"); + + let child_thread = manager + .get_thread(child_thread_id) + .await + .expect("child thread should exist"); + timeout(Duration::from_secs(2), async { + loop { + let history_items = child_thread + .codex + .session + .clone_history() + .await + .raw_items() + .to_vec(); + let recorded = history_items.iter().any(|item| { + matches!( + item, + ResponseItem::Message { role, content, .. } + if role == "assistant" + && content.iter().any(|content_item| matches!( + content_item, + ContentItem::OutputText { text } + if text + == "author: /root\nrecipient: /root/test_process\nother_recipients: []\nContent: continue" + )) + ) + }); + let saw_user_message = history_items.iter().any(|item| { + matches!( + item, + ResponseItem::Message { role, content, .. } + if role == "user" + && content.iter().any(|content_item| matches!( + content_item, + ContentItem::InputText { text } if text == "continue" + )) + ) + }); + if recorded && !saw_user_message { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("v2 send_input should record assistant envelope"); +} + +#[tokio::test] +async fn multi_agent_v2_send_input_accepts_structured_items() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + let mut config = turn.config.as_ref().clone(); + let _ = config.features.enable(Feature::MultiAgentV2); + turn.config = Arc::new(config); + let session = Arc::new(session); + let turn = Arc::new(turn); + + SpawnAgentHandler + .handle(invocation( + session.clone(), + turn.clone(), + "spawn_agent", + function_payload(json!({ + "message": "boot worker", + "task_name": "worker" + })), + )) + .await + .expect("spawn worker"); + let agent_id = session + .services + .agent_control + .resolve_agent_reference(session.conversation_id, &turn.session_source, "worker") + .await + .expect("worker should resolve"); + let thread = manager + .get_thread(agent_id) + .await + .expect("worker thread should exist"); + let invocation = invocation( + session, + turn, + "send_input", + function_payload(json!({ + "target": agent_id.to_string(), + "items": [ + {"type": "mention", "name": "drive", "path": "app://google_drive"}, + {"type": "text", "text": "read the folder"} + ] + })), + ); + + SendInputHandler + .handle(invocation) + .await + .expect("structured items should be accepted in v2"); + + let expected = Op::UserInput { + items: vec![ + UserInput::Mention { + name: "drive".to_string(), + path: "app://google_drive".to_string(), + }, + UserInput::Text { + text: "read the folder".to_string(), + text_elements: Vec::new(), + }, + ], + final_output_json_schema: None, + }; + let captured = manager + .captured_ops() + .into_iter() + .find(|(id, op)| *id == agent_id && *op == expected); + assert_eq!(captured, Some((agent_id, expected))); + + timeout(Duration::from_secs(2), async { + loop { + let history_items = thread + .codex + .session + .clone_history() + .await + .raw_items() + .to_vec(); + let recorded_assistant_envelope = history_items.iter().any(|item| { + matches!( + item, + ResponseItem::Message { role, content, .. } + if role == "assistant" + && content.iter().any(|content_item| matches!( + content_item, + ContentItem::OutputText { text } + if text + == "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: [mention:$drive](app://google_drive)\nread the folder" + )) + ) + }); + let saw_user_message = history_items.iter().any(|item| { + matches!( + item, + ResponseItem::Message { role, content, .. } + if role == "user" + && content.iter().any(|content_item| matches!( + content_item, + ContentItem::InputText { text } + if text == "read the folder" + || text == "[mention:$drive](app://google_drive)\nread the folder" + )) + ) + }); + if !recorded_assistant_envelope && saw_user_message { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("structured items should stay on the legacy user-input path"); + + let _ = thread + .submit(Op::Shutdown {}) + .await + .expect("shutdown should submit"); +} + +#[tokio::test] +async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + let mut config = turn.config.as_ref().clone(); + let _ = config.features.enable(Feature::MultiAgentV2); + turn.config = Arc::new(config); + let session = Arc::new(session); + let turn = Arc::new(turn); + + SpawnAgentHandler + .handle(invocation( + session.clone(), + turn.clone(), + "spawn_agent", + function_payload(json!({ + "message": "boot worker", + "task_name": "worker" + })), + )) + .await + .expect("spawn worker"); + let agent_id = session + .services + .agent_control + .resolve_agent_reference(session.conversation_id, &turn.session_source, "worker") + .await + .expect("worker should resolve"); + let thread = manager + .get_thread(agent_id) + .await + .expect("worker thread should exist"); + + let active_turn = thread.codex.session.new_default_turn().await; + thread + .codex + .session + .spawn_task( + Arc::clone(&active_turn), + vec![UserInput::Text { + text: "working".to_string(), + text_elements: Vec::new(), + }], + NeverEndingTask, + ) + .await; + + SendInputHandler + .handle(invocation( + session, + turn, + "send_input", + function_payload(json!({ + "target": agent_id.to_string(), + "message": "continue", + "interrupt": true + })), + )) + .await + .expect("interrupting v2 send_input should succeed"); + + let ops = manager.captured_ops(); + let ops_for_agent: Vec<&Op> = ops + .iter() + .filter_map(|(id, op)| (*id == agent_id).then_some(op)) + .collect(); + assert!(ops_for_agent.iter().any(|op| matches!(op, Op::Interrupt))); + assert!(!ops_for_agent.iter().any(|op| matches!( + op, + Op::UserInput { items, .. } + if items.iter().any(|item| matches!( + item, + UserInput::Text { text, .. } if text == "continue" + )) + ))); + + timeout(Duration::from_secs(5), async { + loop { + let history_items = thread + .codex + .session + .clone_history() + .await + .raw_items() + .to_vec(); + let saw_envelope = history_items.iter().any(|item| { + matches!( + item, + ResponseItem::Message { role, content, .. } + if role == "assistant" + && content.iter().any(|content_item| matches!( + content_item, + ContentItem::OutputText { text } + if text + == "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue" + )) + ) + }); + let saw_user_message = history_items.iter().any(|item| { + matches!( + item, + ResponseItem::Message { role, content, .. } + if role == "user" + && content.iter().any(|content_item| matches!( + content_item, + ContentItem::InputText { text } if text == "continue" + )) + ) + }); + if saw_envelope && !saw_user_message { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("interrupting v2 send_input should preserve the redirected message"); + + let _ = thread + .submit(Op::Shutdown {}) + .await + .expect("shutdown should submit"); +} + +#[tokio::test] +async fn multi_agent_v2_spawn_includes_agent_id_key_when_named() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + turn.config = Arc::new(config); + + let output = SpawnAgentHandler + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "task_name": "test_process" + })), + )) + .await + .expect("spawn_agent should succeed"); + let (content, success) = expect_text_output(output); + let result: serde_json::Value = + serde_json::from_str(&content).expect("spawn_agent result should be json"); + + assert_eq!(result["agent_id"], serde_json::Value::Null); + assert_eq!(result["task_name"], "/root/test_process"); + assert!(result.get("nickname").is_some()); + assert_eq!(success, Some(true)); +} + +#[tokio::test] +async fn multi_agent_v2_spawn_surfaces_task_name_validation_errors() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + turn.config = Arc::new(config); + + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "task_name": "BadName" + })), + ); + let Err(err) = SpawnAgentHandler.handle(invocation).await else { + panic!("invalid agent name should be rejected"); + }; + assert_eq!( + err, + FunctionCallError::RespondToModel( + "agent_name must use only lowercase letters, digits, and underscores".to_string() + ) + ); +} + #[tokio::test] async fn spawn_agent_reapplies_runtime_sandbox_after_role_config() { fn pick_allowed_sandbox_policy( @@ -293,7 +810,7 @@ async fn spawn_agent_reapplies_runtime_sandbox_after_role_config() { let (content, _) = expect_text_output(output); let result: SpawnAgentResult = serde_json::from_str(&content).expect("spawn_agent result should be json"); - let agent_id = agent_id(&result.agent_id).expect("agent_id should be valid"); + let agent_id = parse_agent_id(&result.agent_id); assert!( result .nickname @@ -334,6 +851,7 @@ async fn spawn_agent_rejects_when_depth_limit_exceeded() { turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: session.conversation_id, depth: max_depth, + agent_path: None, agent_nickname: None, agent_role: None, }); @@ -373,6 +891,7 @@ async fn spawn_agent_allows_depth_up_to_configured_max_depth() { turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: session.conversation_id, depth: DEFAULT_AGENT_MAX_DEPTH, + agent_path: None, agent_nickname: None, agent_role: None, }); @@ -407,7 +926,7 @@ async fn send_input_rejects_empty_message() { Arc::new(session), Arc::new(turn), "send_input", - function_payload(json!({"id": ThreadId::new().to_string(), "message": ""})), + function_payload(json!({"target": ThreadId::new().to_string(), "message": ""})), ); let Err(err) = SendInputHandler.handle(invocation).await else { panic!("empty message should be rejected"); @@ -426,7 +945,7 @@ async fn send_input_rejects_when_message_and_items_are_both_set() { Arc::new(turn), "send_input", function_payload(json!({ - "id": ThreadId::new().to_string(), + "target": ThreadId::new().to_string(), "message": "hello", "items": [{"type": "mention", "name": "drive", "path": "app://drive"}] })), @@ -449,7 +968,7 @@ async fn send_input_rejects_invalid_id() { Arc::new(session), Arc::new(turn), "send_input", - function_payload(json!({"id": "not-a-uuid", "message": "hi"})), + function_payload(json!({"target": "not-a-uuid", "message": "hi"})), ); let Err(err) = SendInputHandler.handle(invocation).await else { panic!("invalid id should be rejected"); @@ -457,7 +976,10 @@ async fn send_input_rejects_invalid_id() { let FunctionCallError::RespondToModel(msg) = err else { panic!("expected respond-to-model error"); }; - assert!(msg.starts_with("invalid agent id not-a-uuid:")); + assert_eq!( + msg, + "agent_name must use only lowercase letters, digits, and underscores" + ); } #[tokio::test] @@ -470,7 +992,7 @@ async fn send_input_reports_missing_agent() { Arc::new(session), Arc::new(turn), "send_input", - function_payload(json!({"id": agent_id.to_string(), "message": "hi"})), + function_payload(json!({"target": agent_id.to_string(), "message": "hi"})), ); let Err(err) = SendInputHandler.handle(invocation).await else { panic!("missing agent should be reported"); @@ -494,7 +1016,7 @@ async fn send_input_interrupts_before_prompt() { Arc::new(turn), "send_input", function_payload(json!({ - "id": agent_id.to_string(), + "target": agent_id.to_string(), "message": "hi", "interrupt": true })), @@ -533,7 +1055,7 @@ async fn send_input_accepts_structured_items() { Arc::new(turn), "send_input", function_payload(json!({ - "id": agent_id.to_string(), + "target": agent_id.to_string(), "items": [ {"type": "mention", "name": "drive", "path": "app://google_drive"}, {"type": "text", "text": "read the folder"} @@ -704,7 +1226,7 @@ async fn resume_agent_restores_closed_agent_and_accepts_send_input() { session, turn, "send_input", - function_payload(json!({"id": agent_id.to_string(), "message": "hello"})), + function_payload(json!({"target": agent_id.to_string(), "message": "hello"})), ); let output = SendInputHandler .handle(send_invocation) @@ -737,6 +1259,7 @@ async fn resume_agent_rejects_when_depth_limit_exceeded() { turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: session.conversation_id, depth: max_depth, + agent_path: None, agent_nickname: None, agent_role: None, }); @@ -766,7 +1289,7 @@ async fn wait_agent_rejects_non_positive_timeout() { Arc::new(turn), "wait_agent", function_payload(json!({ - "ids": [ThreadId::new().to_string()], + "targets": [ThreadId::new().to_string()], "timeout_ms": 0 })), ); @@ -780,13 +1303,13 @@ async fn wait_agent_rejects_non_positive_timeout() { } #[tokio::test] -async fn wait_agent_rejects_invalid_id() { +async fn wait_agent_rejects_invalid_target() { let (session, turn) = make_session_and_context().await; let invocation = invocation( Arc::new(session), Arc::new(turn), "wait_agent", - function_payload(json!({"ids": ["invalid"]})), + function_payload(json!({"targets": ["invalid"]})), ); let Err(err) = WaitAgentHandler.handle(invocation).await else { panic!("invalid id should be rejected"); @@ -794,27 +1317,62 @@ async fn wait_agent_rejects_invalid_id() { let FunctionCallError::RespondToModel(msg) = err else { panic!("expected respond-to-model error"); }; - assert!(msg.starts_with("invalid agent id invalid:")); + assert_eq!(msg, "live agent path `/root/invalid` not found"); } #[tokio::test] -async fn wait_agent_rejects_empty_ids() { +async fn wait_agent_rejects_empty_targets() { let (session, turn) = make_session_and_context().await; let invocation = invocation( Arc::new(session), Arc::new(turn), "wait_agent", - function_payload(json!({"ids": []})), + function_payload(json!({"targets": []})), ); let Err(err) = WaitAgentHandler.handle(invocation).await else { panic!("empty ids should be rejected"); }; assert_eq!( err, - FunctionCallError::RespondToModel("ids must be non-empty".to_string()) + FunctionCallError::RespondToModel("agent targets must be non-empty".to_string()) ); } +#[tokio::test] +async fn multi_agent_v2_wait_agent_accepts_targets_argument() { + let (mut session, mut turn) = make_session_and_context().await; + let target = ThreadId::new().to_string(); + let manager = thread_manager(); + session.services.agent_control = manager.agent_control(); + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + turn.config = Arc::new(config); + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "wait_agent", + function_payload(json!({"targets": [target.clone()]})), + ); + let output = WaitAgentHandler + .handle(invocation) + .await + .expect("targets should be accepted in v2 mode"); + let (content, success) = expect_text_output(output); + let result: wait::WaitAgentResult = + serde_json::from_str(&content).expect("wait_agent result should be json"); + assert_eq!( + result, + wait::WaitAgentResult { + status: HashMap::from([(target, AgentStatus::NotFound)]), + timed_out: false, + } + ); + assert_eq!(success, None); +} + #[tokio::test] async fn wait_agent_returns_not_found_for_missing_agents() { let (mut session, turn) = make_session_and_context().await; @@ -827,7 +1385,7 @@ async fn wait_agent_returns_not_found_for_missing_agents() { Arc::new(turn), "wait_agent", function_payload(json!({ - "ids": [id_a.to_string(), id_b.to_string()], + "targets": [id_a.to_string(), id_b.to_string()], "timeout_ms": 1000 })), ); @@ -841,7 +1399,10 @@ async fn wait_agent_returns_not_found_for_missing_agents() { assert_eq!( result, wait::WaitAgentResult { - status: HashMap::from([(id_a, AgentStatus::NotFound), (id_b, AgentStatus::NotFound),]), + status: HashMap::from([ + (id_a.to_string(), AgentStatus::NotFound), + (id_b.to_string(), AgentStatus::NotFound), + ]), timed_out: false } ); @@ -861,7 +1422,7 @@ async fn wait_agent_times_out_when_status_is_not_final() { Arc::new(turn), "wait_agent", function_payload(json!({ - "ids": [agent_id.to_string()], + "targets": [agent_id.to_string()], "timeout_ms": MIN_WAIT_TIMEOUT_MS })), ); @@ -901,7 +1462,7 @@ async fn wait_agent_clamps_short_timeouts_to_minimum() { Arc::new(turn), "wait_agent", function_payload(json!({ - "ids": [agent_id.to_string()], + "targets": [agent_id.to_string()], "timeout_ms": 10 })), ); @@ -951,7 +1512,7 @@ async fn wait_agent_returns_final_status_without_timeout() { Arc::new(turn), "wait_agent", function_payload(json!({ - "ids": [agent_id.to_string()], + "targets": [agent_id.to_string()], "timeout_ms": 1000 })), ); @@ -965,13 +1526,106 @@ async fn wait_agent_returns_final_status_without_timeout() { assert_eq!( result, wait::WaitAgentResult { - status: HashMap::from([(agent_id, AgentStatus::Shutdown)]), + status: HashMap::from([(agent_id.to_string(), AgentStatus::Shutdown)]), timed_out: false } ); assert_eq!(success, None); } +#[tokio::test] +async fn multi_agent_v2_wait_agent_returns_statuses_keyed_by_path() { + #[derive(Debug, Deserialize)] + struct SpawnAgentResult { + task_name: String, + } + + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + turn.config = Arc::new(config); + + let session = Arc::new(session); + let turn = Arc::new(turn); + let spawn_output = SpawnAgentHandler + .handle(invocation( + session.clone(), + turn.clone(), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "task_name": "test_process" + })), + )) + .await + .expect("spawn_agent should succeed"); + let (content, _) = expect_text_output(spawn_output); + let spawn_result: SpawnAgentResult = + serde_json::from_str(&content).expect("spawn result should parse"); + + let agent_id = session + .services + .agent_control + .resolve_agent_reference( + session.conversation_id, + &turn.session_source, + "test_process", + ) + .await + .expect("relative path should resolve"); + let mut status_rx = manager + .agent_control() + .subscribe_status(agent_id) + .await + .expect("subscribe should succeed"); + + let child_thread = manager + .get_thread(agent_id) + .await + .expect("child should exist"); + let _ = child_thread + .submit(Op::Shutdown {}) + .await + .expect("shutdown should submit"); + let _ = timeout(Duration::from_secs(1), status_rx.changed()) + .await + .expect("shutdown status should arrive"); + + let wait_output = WaitAgentHandler + .handle(invocation( + session, + turn, + "wait_agent", + function_payload(json!({ + "targets": ["test_process"], + "timeout_ms": 1000 + })), + )) + .await + .expect("wait_agent should succeed"); + let (content, success) = expect_text_output(wait_output); + let result: wait::WaitAgentResult = + serde_json::from_str(&content).expect("wait_agent result should be json"); + assert_eq!( + result, + wait::WaitAgentResult { + status: HashMap::from([(spawn_result.task_name, AgentStatus::Shutdown)]), + timed_out: false, + } + ); + assert_eq!(success, None); +} + #[tokio::test] async fn close_agent_submits_shutdown_and_returns_previous_status() { let (mut session, turn) = make_session_and_context().await; @@ -986,7 +1640,7 @@ async fn close_agent_submits_shutdown_and_returns_previous_status() { Arc::new(session), Arc::new(turn), "close_agent", - function_payload(json!({"id": agent_id.to_string()})), + function_payload(json!({"target": agent_id.to_string()})), ); let output = CloseAgentHandler .handle(invocation) @@ -1038,13 +1692,12 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr let (child_content, child_success) = expect_text_output(child_spawn_output); let child_result: serde_json::Value = serde_json::from_str(&child_content).expect("child spawn result should be json"); - let child_thread_id = agent_id( + let child_thread_id = parse_agent_id( child_result .get("agent_id") .and_then(serde_json::Value::as_str) .expect("child spawn result should include agent_id"), - ) - .expect("child agent_id should be valid"); + ); assert_eq!(child_success, Some(true)); let child_thread = manager @@ -1064,13 +1717,12 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr let (grandchild_content, grandchild_success) = expect_text_output(grandchild_spawn_output); let grandchild_result: serde_json::Value = serde_json::from_str(&grandchild_content).expect("grandchild spawn result should be json"); - let grandchild_thread_id = agent_id( + let grandchild_thread_id = parse_agent_id( grandchild_result .get("agent_id") .and_then(serde_json::Value::as_str) .expect("grandchild spawn result should include agent_id"), - ) - .expect("grandchild agent_id should be valid"); + ); assert_eq!(grandchild_success, Some(true)); let close_output = CloseAgentHandler @@ -1078,7 +1730,7 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr parent_session.clone(), parent_session.new_default_turn().await, "close_agent", - function_payload(json!({"id": child_thread_id.to_string()})), + function_payload(json!({"target": child_thread_id.to_string()})), )) .await .expect("close_agent should close the child subtree"); @@ -1130,7 +1782,7 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr parent_session.clone(), parent_session.new_default_turn().await, "close_agent", - function_payload(json!({"id": child_thread_id.to_string()})), + function_payload(json!({"target": child_thread_id.to_string()})), )) .await .expect("close_agent should be repeatable for the child subtree"); diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index 8544eb404a..345f7ce06f 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -4,7 +4,6 @@ use crate::codex::TurnContext; use crate::function_tool::FunctionCallError; use crate::mcp_connection_manager::ToolInfo; use crate::sandboxing::SandboxPermissions; -use crate::tools::code_mode::is_code_mode_nested_tool; use crate::tools::context::SharedTurnDiffTracker; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; @@ -67,7 +66,7 @@ impl ToolRouter { specs .iter() .filter_map(|configured_tool| { - if !is_code_mode_nested_tool(configured_tool.spec.name()) { + if !codex_code_mode::is_code_mode_nested_tool(configured_tool.spec.name()) { Some(configured_tool.spec.clone()) } else { None diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 18afb20bc5..6ff8349b53 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -33,7 +33,6 @@ use crate::tools::sandboxing::ToolError; use crate::tools::sandboxing::ToolRuntime; use crate::tools::sandboxing::sandbox_override_for_first_attempt; use crate::tools::sandboxing::with_cached_approval; -use codex_features::Feature; use codex_network_proxy::NetworkProxy; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::ReviewDecision; @@ -227,9 +226,7 @@ impl ToolRuntime for ShellRuntime { &req.cwd, &req.explicit_env_overrides, ); - let command = if matches!(session_shell.shell_type, ShellType::PowerShell) - && ctx.session.features().enabled(Feature::PowershellUtf8) - { + let command = if matches!(session_shell.shell_type, ShellType::PowerShell) { prefix_powershell_script_with_utf8(&command) } else { command diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 0b64092156..49ef0e6385 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -36,7 +36,6 @@ use crate::unified_exec::NoopSpawnLifecycle; use crate::unified_exec::UnifiedExecError; use crate::unified_exec::UnifiedExecProcess; use crate::unified_exec::UnifiedExecProcessManager; -use codex_features::Feature; use codex_network_proxy::NetworkProxy; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::ReviewDecision; @@ -200,9 +199,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt &req.cwd, &req.explicit_env_overrides, ); - let command = if matches!(session_shell.shell_type, ShellType::PowerShell) - && ctx.session.features().enabled(Feature::PowershellUtf8) - { + let command = if matches!(session_shell.shell_type, ShellType::PowerShell) { prefix_powershell_script_with_utf8(&command) } else { command diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 662e97d107..2e0a413a6f 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -11,9 +11,6 @@ use crate::shell::Shell; use crate::shell::ShellType; use crate::tools::code_mode::PUBLIC_TOOL_NAME; use crate::tools::code_mode::WAIT_TOOL_NAME; -use crate::tools::code_mode::is_code_mode_nested_tool; -use crate::tools::code_mode::tool_description as code_mode_tool_description; -use crate::tools::code_mode::wait_tool_description as code_mode_wait_tool_description; use crate::tools::code_mode_description::augment_tool_spec_for_code_mode; use crate::tools::discoverable::DiscoverablePluginInfo; use crate::tools::discoverable::DiscoverableTool; @@ -129,20 +126,29 @@ fn agent_status_output_schema() -> JsonValue { }) } -fn spawn_agent_output_schema() -> JsonValue { +fn spawn_agent_output_schema(multi_agent_v2: bool) -> JsonValue { + let task_name_description = if multi_agent_v2 { + "Canonical task name for the spawned agent." + } else { + "Canonical task name for the spawned agent when one was assigned." + }; json!({ "type": "object", "properties": { "agent_id": { - "type": "string", - "description": "Thread identifier for the spawned agent." + "type": ["string", "null"], + "description": "Thread identifier for the spawned agent when no task name was assigned." + }, + "task_name": { + "type": ["string", "null"], + "description": task_name_description }, "nickname": { "type": ["string", "null"], "description": "User-facing nickname for the spawned agent when available." } }, - "required": ["agent_id", "nickname"], + "required": ["agent_id", "task_name", "nickname"], "additionalProperties": false }) } @@ -178,7 +184,7 @@ fn wait_output_schema() -> JsonValue { "properties": { "status": { "type": "object", - "description": "Final statuses keyed by agent id for agents that finished before the timeout.", + "description": "Final statuses keyed by canonical task name when available, otherwise by agent id.", "additionalProperties": agent_status_output_schema() }, "timed_out": { @@ -276,6 +282,7 @@ pub(crate) struct ToolsConfig { pub js_repl_tools_only: bool, pub can_request_original_image_detail: bool, pub collab_tools: bool, + pub multi_agent_v2: bool, pub artifact_tools: bool, pub request_user_input: bool, pub default_mode_request_user_input: bool, @@ -325,6 +332,7 @@ impl ToolsConfig { let include_js_repl_tools_only = include_js_repl && features.enabled(Feature::JsReplToolsOnly); let include_collab_tools = features.enabled(Feature::Collab); + let include_multi_agent_v2 = features.enabled(Feature::MultiAgentV2); let include_agent_jobs = features.enabled(Feature::SpawnCsv); let include_request_user_input = !matches!(session_source, SessionSource::SubAgent(_)); let include_default_mode_request_user_input = @@ -408,6 +416,7 @@ impl ToolsConfig { js_repl_tools_only: include_js_repl_tools_only, can_request_original_image_detail: include_original_image_detail, collab_tools: include_collab_tools, + multi_agent_v2: include_multi_agent_v2, artifact_tools: include_artifact_tools, request_user_input: include_request_user_input, default_mode_request_user_input: include_default_mode_request_user_input, @@ -821,7 +830,7 @@ fn create_wait_tool() -> ToolSpec { name: WAIT_TOOL_NAME.to_string(), description: format!( "Waits on a yielded `{PUBLIC_TOOL_NAME}` cell and returns new output or completion.\n{}", - code_mode_wait_tool_description().trim() + codex_code_mode::build_wait_tool_description().trim() ), strict: false, parameters: JsonSchema::Object { @@ -1076,7 +1085,8 @@ fn create_collab_input_items_schema() -> JsonSchema { fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec { let available_models_description = spawn_agent_models_description(&config.available_models); - let properties = BTreeMap::from([ + let return_value_description = "Returns the canonical task name when the spawned agent was named, otherwise the agent id, plus the user-facing nickname when available."; + let mut properties = BTreeMap::from([ ( "message".to_string(), JsonSchema::String { @@ -1123,6 +1133,15 @@ fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec { }, ), ]); + properties.insert( + "task_name".to_string(), + JsonSchema::String { + description: Some( + "Optional task name for the new agent. Use lowercase letters, digits, and underscores." + .to_string(), + ), + }, + ); ToolSpec::Function(ResponsesApiTool { name: "spawn_agent".to_string(), @@ -1131,7 +1150,7 @@ fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec { Only use `spawn_agent` if and only if the user explicitly asks for sub-agents, delegation, or parallel agent work. Requests for depth, thoroughness, research, investigation, or detailed codebase analysis do not count as permission to spawn. Agent-role guidance below only helps choose which agent to use after spawning is already authorized; it never authorizes spawning by itself. - Spawn a sub-agent for a well-scoped task. Returns the agent id (and user-facing nickname when available) to use to communicate with this agent. This spawn_agent tool provides you access to smaller but more efficient sub-agents. A mini model can solve many tasks faster than the main model. You should follow the rules and guidelines below to use this tool. + Spawn a sub-agent for a well-scoped task. {return_value_description} This spawn_agent tool provides you access to smaller but more efficient sub-agents. A mini model can solve many tasks faster than the main model. You should follow the rules and guidelines below to use this tool. {available_models_description} ### When to delegate vs. do the subtask yourself @@ -1170,7 +1189,7 @@ fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec { required: None, additional_properties: Some(false.into()), }, - output_schema: Some(spawn_agent_output_schema()), + output_schema: Some(spawn_agent_output_schema(config.multi_agent_v2)), }) } @@ -1335,9 +1354,11 @@ fn create_report_agent_job_result_tool() -> ToolSpec { fn create_send_input_tool() -> ToolSpec { let properties = BTreeMap::from([ ( - "id".to_string(), + "target".to_string(), JsonSchema::String { - description: Some("Agent id to message (from spawn_agent).".to_string()), + description: Some( + "Agent id or canonical task name to message (from spawn_agent).".to_string(), + ), }, ), ( @@ -1369,7 +1390,7 @@ fn create_send_input_tool() -> ToolSpec { defer_loading: None, parameters: JsonSchema::Object { properties, - required: Some(vec!["id".to_string()]), + required: Some(vec!["target".to_string()]), additional_properties: Some(false.into()), }, output_schema: Some(send_input_output_schema()), @@ -1404,11 +1425,11 @@ fn create_resume_agent_tool() -> ToolSpec { fn create_wait_agent_tool() -> ToolSpec { let mut properties = BTreeMap::new(); properties.insert( - "ids".to_string(), + "targets".to_string(), JsonSchema::Array { items: Box::new(JsonSchema::String { description: None }), description: Some( - "Agent ids to wait on. Pass multiple ids to wait for whichever finishes first." + "Agent ids or canonical task names to wait on. Pass multiple targets to wait for whichever finishes first." .to_string(), ), }, @@ -1430,7 +1451,7 @@ fn create_wait_agent_tool() -> ToolSpec { defer_loading: None, parameters: JsonSchema::Object { properties, - required: Some(vec!["ids".to_string()]), + required: Some(vec!["targets".to_string()]), additional_properties: Some(false.into()), }, output_schema: Some(wait_output_schema()), @@ -1556,9 +1577,11 @@ fn create_request_permissions_tool() -> ToolSpec { fn create_close_agent_tool() -> ToolSpec { let mut properties = BTreeMap::new(); properties.insert( - "id".to_string(), + "target".to_string(), JsonSchema::String { - description: Some("Agent id to close (from spawn_agent).".to_string()), + description: Some( + "Agent id or canonical task name to close (from spawn_agent).".to_string(), + ), }, ); @@ -1569,7 +1592,7 @@ fn create_close_agent_tool() -> ToolSpec { defer_loading: None, parameters: JsonSchema::Object { properties, - required: Some(vec!["id".to_string()]), + required: Some(vec!["target".to_string()]), additional_properties: Some(false.into()), }, output_schema: Some(close_agent_output_schema()), @@ -2150,7 +2173,10 @@ SOURCE: /[\s\S]+/ ToolSpec::Freeform(FreeformTool { name: PUBLIC_TOOL_NAME.to_string(), - description: code_mode_tool_description(enabled_tools, code_mode_only_enabled), + description: codex_code_mode::build_exec_tool_description( + enabled_tools, + code_mode_only_enabled, + ), format: FreeformToolFormat { r#type: "grammar".to_string(), syntax: "lark".to_string(), @@ -2621,7 +2647,7 @@ pub(crate) fn build_specs_with_discoverable_tools( ToolSpec::Freeform(tool) => (tool.name, tool.description), _ => return None, }; - is_code_mode_nested_tool(&name).then_some((name, description)) + codex_code_mode::is_code_mode_nested_tool(&name).then_some((name, description)) }) .collect::>(); enabled_tools.sort_by(|left, right| left.0.cmp(&right.0)); @@ -2966,12 +2992,15 @@ pub(crate) fn build_specs_with_discoverable_tools( /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); - push_tool_spec( - &mut builder, - create_resume_agent_tool(), - /*supports_parallel_tool_calls*/ false, - config.code_mode_enabled, - ); + if !config.multi_agent_v2 { + push_tool_spec( + &mut builder, + create_resume_agent_tool(), + /*supports_parallel_tool_calls*/ false, + config.code_mode_enabled, + ); + builder.register_handler("resume_agent", Arc::new(ResumeAgentHandler)); + } push_tool_spec( &mut builder, create_wait_agent_tool(), @@ -2986,7 +3015,6 @@ pub(crate) fn build_specs_with_discoverable_tools( ); builder.register_handler("spawn_agent", Arc::new(SpawnAgentHandler)); builder.register_handler("send_input", Arc::new(SendInputHandler)); - builder.register_handler("resume_agent", Arc::new(ResumeAgentHandler)); builder.register_handler("wait_agent", Arc::new(WaitAgentHandler)); builder.register_handler("close_agent", Arc::new(CloseAgentHandler)); } diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index 3142dd46a3..1cb6bb1664 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -469,12 +469,15 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() { create_view_image_tool(config.can_request_original_image_detail), create_spawn_agent_tool(&config), create_send_input_tool(), - create_resume_agent_tool(), create_wait_agent_tool(), create_close_agent_tool(), ] { expected.insert(tool_name(&spec).to_string(), spec); } + if !config.multi_agent_v2 { + let spec = create_resume_agent_tool(); + expected.insert(tool_name(&spec).to_string(), spec); + } if config.exec_permission_approvals_enabled { let spec = create_request_permissions_tool(); @@ -520,6 +523,96 @@ fn test_build_specs_collab_tools_enabled() { assert_lacks_tool_name(&tools, "spawn_agents_on_csv"); } +#[test] +fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::Collab); + features.enable(Feature::MultiAgentV2); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + + let spawn_agent = find_tool(&tools, "spawn_agent"); + let ToolSpec::Function(ResponsesApiTool { + parameters, + output_schema, + .. + }) = &spawn_agent.spec + else { + panic!("spawn_agent should be a function tool"); + }; + let JsonSchema::Object { + properties, + required, + .. + } = parameters + else { + panic!("spawn_agent should use object params"); + }; + assert!(properties.contains_key("task_name")); + assert_eq!(required.as_ref(), None); + let output_schema = output_schema + .as_ref() + .expect("spawn_agent should define output schema"); + assert_eq!( + output_schema["required"], + json!(["agent_id", "task_name", "nickname"]) + ); + + let send_input = find_tool(&tools, "send_input"); + let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &send_input.spec else { + panic!("send_input should be a function tool"); + }; + let JsonSchema::Object { + properties, + required, + .. + } = parameters + else { + panic!("send_input should use object params"); + }; + assert!(properties.contains_key("target")); + assert_eq!(required.as_ref(), Some(&vec!["target".to_string()])); + + let wait_agent = find_tool(&tools, "wait_agent"); + let ToolSpec::Function(ResponsesApiTool { + parameters, + output_schema, + .. + }) = &wait_agent.spec + else { + panic!("wait_agent should be a function tool"); + }; + let JsonSchema::Object { + properties, + required, + .. + } = parameters + else { + panic!("wait_agent should use object params"); + }; + assert!(properties.contains_key("targets")); + assert_eq!(required.as_ref(), Some(&vec!["targets".to_string()])); + let output_schema = output_schema + .as_ref() + .expect("wait_agent should define output schema"); + assert_eq!( + output_schema["properties"]["status"]["description"], + json!("Final statuses keyed by canonical task name when available, otherwise by agent id.") + ); + assert_lacks_tool_name(&tools, "resume_agent"); +} + #[test] fn test_build_specs_enable_fanout_enables_agent_jobs_and_collab_tools() { let config = test_config(); diff --git a/codex-rs/core/tests/common/Cargo.toml b/codex-rs/core/tests/common/Cargo.toml index 7377e40f53..1e0b8d6cc2 100644 --- a/codex-rs/core/tests/common/Cargo.toml +++ b/codex-rs/core/tests/common/Cargo.toml @@ -13,6 +13,7 @@ assert_cmd = { workspace = true } base64 = { workspace = true } codex-arg0 = { workspace = true } codex-core = { workspace = true } +codex-exec-server = { workspace = true } codex-features = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index 6209ded404..0b31793402 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -289,6 +289,29 @@ pub fn sandbox_network_env_var() -> &'static str { codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR } +const REMOTE_ENV_ENV_VAR: &str = "CODEX_TEST_REMOTE_ENV"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RemoteEnvConfig { + pub container_name: String, +} + +pub fn get_remote_test_env() -> Option { + if std::env::var_os(REMOTE_ENV_ENV_VAR).is_none() { + eprintln!("Skipping test because {REMOTE_ENV_ENV_VAR} is not set."); + return None; + } + + let container_name = std::env::var(REMOTE_ENV_ENV_VAR) + .unwrap_or_else(|_| panic!("{REMOTE_ENV_ENV_VAR} must be set")); + assert!( + !container_name.trim().is_empty(), + "{REMOTE_ENV_ENV_VAR} must not be empty" + ); + + Some(RemoteEnvConfig { container_name }) +} + pub fn format_with_current_shell(command: &str) -> Vec { codex_core::shell::default_user_shell().derive_exec_args(command, /*use_login_shell*/ true) } diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 116a30d28d..6df93bcd85 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -1,10 +1,16 @@ use std::mem::swap; use std::path::Path; use std::path::PathBuf; +use std::process::Command; use std::sync::Arc; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; +use std::time::Duration; +use std::time::Instant; use anyhow::Context; use anyhow::Result; +use anyhow::anyhow; use codex_core::CodexAuth; use codex_core::CodexThread; use codex_core::ModelProviderInfo; @@ -14,6 +20,8 @@ use codex_core::config::Config; use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig; use codex_core::shell::Shell; use codex_core::shell::get_shell_by_model_provided_path; +use codex_exec_server::CreateDirectoryOptions; +use codex_exec_server::ExecutorFileSystem; use codex_features::Feature; use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ModelsResponse; @@ -24,10 +32,13 @@ use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionSource; use codex_protocol::user_input::UserInput; +use codex_utils_absolute_path::AbsolutePathBuf; use serde_json::Value; use tempfile::TempDir; use wiremock::MockServer; +use crate::RemoteEnvConfig; +use crate::get_remote_test_env; use crate::load_default_config_for_test; use crate::responses::WebSocketTestServer; use crate::responses::output_value_to_text; @@ -41,6 +52,254 @@ use wiremock::matchers::path_regex; type ConfigMutator = dyn FnOnce(&mut Config) + Send; type PreBuildHook = dyn FnOnce(&Path) + Send + 'static; const TEST_MODEL_WITH_EXPERIMENTAL_TOOLS: &str = "test-gpt-5.1-codex"; +const REMOTE_EXEC_SERVER_START_TIMEOUT: Duration = Duration::from_secs(5); +const REMOTE_EXEC_SERVER_POLL_INTERVAL: Duration = Duration::from_millis(25); +static REMOTE_EXEC_SERVER_INSTANCE_COUNTER: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug)] +struct RemoteExecServerProcess { + container_name: String, + pid: u32, + remote_exec_server_path: String, + stdout_path: String, + cleanup_paths: Vec, +} + +impl Drop for RemoteExecServerProcess { + fn drop(&mut self) { + let cleanup_paths = self.cleanup_paths.join(" "); + let cleanup_paths_script = if cleanup_paths.is_empty() { + String::new() + } else { + format!("rm -rf {cleanup_paths}; ") + }; + let script = format!( + "if kill -0 {pid} 2>/dev/null; then kill {pid}; fi; {cleanup_paths_script}rm -f {remote_exec_server_path} {stdout_path}", + pid = self.pid, + cleanup_paths_script = cleanup_paths_script, + remote_exec_server_path = self.remote_exec_server_path, + stdout_path = self.stdout_path + ); + let _ = docker_command_capture_stdout(["exec", &self.container_name, "sh", "-lc", &script]); + } +} + +impl RemoteExecServerProcess { + fn register_cleanup_path(&mut self, path: &Path) { + self.cleanup_paths.push(path.display().to_string()); + } +} + +#[derive(Debug)] +pub struct TestEnv { + environment: codex_exec_server::Environment, + cwd: PathBuf, + _local_cwd_temp_dir: Option, + _remote_exec_server_process: Option, +} + +impl TestEnv { + pub async fn local() -> Result { + let local_cwd_temp_dir = TempDir::new()?; + let cwd = local_cwd_temp_dir.path().to_path_buf(); + let environment = + codex_exec_server::Environment::create(/*experimental_exec_server_url*/ None).await?; + Ok(Self { + environment, + cwd, + _local_cwd_temp_dir: Some(local_cwd_temp_dir), + _remote_exec_server_process: None, + }) + } + + pub fn environment(&self) -> &codex_exec_server::Environment { + &self.environment + } + + pub fn experimental_exec_server_url(&self) -> Option<&str> { + self.environment.experimental_exec_server_url() + } +} + +pub async fn test_env() -> Result { + match get_remote_test_env() { + Some(remote_env) => { + let mut remote_process = start_remote_exec_server(&remote_env)?; + let remote_ip = remote_container_ip(&remote_env.container_name)?; + let websocket_url = rewrite_websocket_host(&remote_process.listen_url, &remote_ip)?; + let environment = codex_exec_server::Environment::create(Some(websocket_url)).await?; + let cwd = remote_aware_cwd_path(); + environment + .get_filesystem() + .create_directory( + &absolute_path(&cwd)?, + CreateDirectoryOptions { recursive: true }, + ) + .await?; + remote_process.process.register_cleanup_path(&cwd); + Ok(TestEnv { + environment, + cwd, + _local_cwd_temp_dir: None, + _remote_exec_server_process: Some(remote_process.process), + }) + } + None => TestEnv::local().await, + } +} + +struct RemoteExecServerStart { + process: RemoteExecServerProcess, + listen_url: String, +} + +fn start_remote_exec_server(remote_env: &RemoteEnvConfig) -> Result { + let container_name = remote_env.container_name.as_str(); + let instance_id = remote_exec_server_instance_id(); + let remote_exec_server_path = format!("/tmp/codex-exec-server-{instance_id}"); + let stdout_path = format!("/tmp/codex-exec-server-{instance_id}.stdout"); + let local_binary = codex_utils_cargo_bin::cargo_bin("codex-exec-server") + .context("resolve codex-exec-server binary")?; + let local_binary = local_binary.to_string_lossy().to_string(); + let remote_binary = format!("{container_name}:{remote_exec_server_path}"); + + docker_command_success(["cp", &local_binary, &remote_binary])?; + docker_command_success([ + "exec", + container_name, + "chmod", + "+x", + &remote_exec_server_path, + ])?; + + let start_script = format!( + "rm -f {stdout_path}; \ +nohup {remote_exec_server_path} --listen ws://0.0.0.0:0 > {stdout_path} 2>&1 & \ +echo $!" + ); + let pid_output = + docker_command_capture_stdout(["exec", container_name, "sh", "-lc", &start_script])?; + let pid = pid_output + .trim() + .parse::() + .with_context(|| format!("parse remote exec-server PID from {pid_output:?}"))?; + + let listen_url = wait_for_remote_listen_url(container_name, &stdout_path)?; + + Ok(RemoteExecServerStart { + process: RemoteExecServerProcess { + container_name: container_name.to_string(), + pid, + remote_exec_server_path, + stdout_path, + cleanup_paths: Vec::new(), + }, + listen_url, + }) +} + +fn remote_aware_cwd_path() -> PathBuf { + PathBuf::from(format!( + "/tmp/codex-core-test-cwd-{}", + remote_exec_server_instance_id() + )) +} + +fn wait_for_remote_listen_url(container_name: &str, stdout_path: &str) -> Result { + let deadline = Instant::now() + REMOTE_EXEC_SERVER_START_TIMEOUT; + loop { + let line = docker_command_capture_stdout([ + "exec", + container_name, + "sh", + "-lc", + &format!("head -n 1 {stdout_path} 2>/dev/null || true"), + ])?; + let listen_url = line.trim(); + if listen_url.starts_with("ws://") { + return Ok(listen_url.to_string()); + } + + if Instant::now() >= deadline { + return Err(anyhow!( + "timed out waiting for remote exec-server listen URL in container `{container_name}` after {REMOTE_EXEC_SERVER_START_TIMEOUT:?}" + )); + } + std::thread::sleep(REMOTE_EXEC_SERVER_POLL_INTERVAL); + } +} + +fn remote_exec_server_instance_id() -> String { + let instance = REMOTE_EXEC_SERVER_INSTANCE_COUNTER.fetch_add(1, Ordering::Relaxed); + format!("{}-{instance}", std::process::id()) +} + +fn remote_container_ip(container_name: &str) -> Result { + let ip = docker_command_capture_stdout([ + "inspect", + "-f", + "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}", + container_name, + ])?; + let ip = ip.trim(); + if ip.is_empty() { + return Err(anyhow!( + "container `{container_name}` has no IP address; cannot connect to remote exec-server" + )); + } + Ok(ip.to_string()) +} + +fn rewrite_websocket_host(listen_url: &str, host: &str) -> Result { + let Some(address) = listen_url.strip_prefix("ws://") else { + return Err(anyhow!( + "unexpected websocket listen URL `{listen_url}`; expected ws://IP:PORT" + )); + }; + let Some((_, port)) = address.rsplit_once(':') else { + return Err(anyhow!( + "unexpected websocket listen URL `{listen_url}`; expected ws://IP:PORT" + )); + }; + Ok(format!("ws://{host}:{port}")) +} + +fn docker_command_success(args: [&str; N]) -> Result<()> { + let output = Command::new("docker") + .args(args) + .output() + .with_context(|| format!("run docker {:?}", args))?; + if !output.status.success() { + return Err(anyhow!( + "docker {:?} failed: stdout={} stderr={}", + args, + String::from_utf8_lossy(&output.stdout).trim(), + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(()) +} + +fn docker_command_capture_stdout(args: [&str; N]) -> Result { + let output = Command::new("docker") + .args(args) + .output() + .with_context(|| format!("run docker {:?}", args))?; + if !output.status.success() { + return Err(anyhow!( + "docker {:?} failed: stdout={} stderr={}", + args, + String::from_utf8_lossy(&output.stdout).trim(), + String::from_utf8_lossy(&output.stderr).trim() + )); + } + String::from_utf8(output.stdout).context("docker stdout must be utf-8") +} + +fn absolute_path(path: &Path) -> Result { + AbsolutePathBuf::try_from(path.to_path_buf()) + .map_err(|err| anyhow!("invalid absolute path {}: {err}", path.display())) +} /// A collection of different ways the model can output an apply_patch call #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -124,6 +383,24 @@ impl TestCodexBuilder { Box::pin(self.build_with_home(server, home, /*resume_from*/ None)).await } + pub async fn build_remote_aware( + &mut self, + server: &wiremock::MockServer, + ) -> anyhow::Result { + let test_env = test_env().await?; + let experimental_exec_server_url = + test_env.experimental_exec_server_url().map(str::to_owned); + let cwd = test_env.cwd.to_path_buf(); + self.config_mutators.push(Box::new(move |config| { + config.experimental_exec_server_url = experimental_exec_server_url; + config.cwd = cwd; + })); + + let mut test = self.build(server).await?; + test._test_env = test_env; + Ok(test) + } + pub async fn build_with_streaming_server( &mut self, server: &StreamingSseServer, @@ -176,7 +453,8 @@ impl TestCodexBuilder { ) -> anyhow::Result { let base_url = format!("{}/v1", server.uri()); let (config, cwd) = self.prepare_config(base_url, &home).await?; - Box::pin(self.build_from_config(config, cwd, home, resume_from)).await + Box::pin(self.build_from_config(config, cwd, home, resume_from, TestEnv::local().await?)) + .await } async fn build_with_home_and_base_url( @@ -186,7 +464,8 @@ impl TestCodexBuilder { resume_from: Option, ) -> anyhow::Result { let (config, cwd) = self.prepare_config(base_url, &home).await?; - Box::pin(self.build_from_config(config, cwd, home, resume_from)).await + Box::pin(self.build_from_config(config, cwd, home, resume_from, TestEnv::local().await?)) + .await } async fn build_from_config( @@ -195,6 +474,7 @@ impl TestCodexBuilder { cwd: Arc, home: Arc, resume_from: Option, + test_env: TestEnv, ) -> anyhow::Result { let auth = self.auth.clone(); let thread_manager = if config.model_catalog.is_some() { @@ -258,6 +538,7 @@ impl TestCodexBuilder { codex: new_conversation.thread, session_configured: new_conversation.session_configured, thread_manager, + _test_env: test_env, }) } @@ -354,6 +635,7 @@ pub struct TestCodex { pub session_configured: SessionConfiguredEvent, pub config: Config, pub thread_manager: Arc, + _test_env: TestEnv, } impl TestCodex { @@ -369,6 +651,14 @@ impl TestCodex { self.cwd_path().join(rel) } + pub fn executor_environment(&self) -> &TestEnv { + &self._test_env + } + + pub fn fs(&self) -> Arc { + self._test_env.environment().get_filesystem() + } + pub async fn submit_turn(&self, prompt: &str) -> Result<()> { self.submit_turn_with_policies( prompt, @@ -431,7 +721,7 @@ impl TestCodex { text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: self.cwd.path().to_path_buf(), + cwd: self.config.cwd.clone(), approval_policy, sandbox_policy, model: session_model, diff --git a/codex-rs/core/tests/suite/auth_refresh.rs b/codex-rs/core/tests/suite/auth_refresh.rs index 23ed87aa98..278124c63a 100644 --- a/codex-rs/core/tests/suite/auth_refresh.rs +++ b/codex-rs/core/tests/suite/auth_refresh.rs @@ -381,6 +381,116 @@ async fn refreshes_token_when_last_refresh_is_stale() -> Result<()> { Ok(()) } +#[serial_test::serial(auth_refresh)] +#[tokio::test] +async fn auth_reloads_disk_auth_when_cached_auth_is_stale() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = MockServer::start().await; + + let ctx = RefreshTokenTestContext::new(&server)?; + let stale_refresh = Utc::now() - Duration::days(9); + let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN); + let initial_auth = AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(initial_tokens), + last_refresh: Some(stale_refresh), + }; + ctx.write_auth(&initial_auth)?; + + let fresh_refresh = Utc::now() - Duration::days(1); + let disk_tokens = build_tokens("disk-access-token", "disk-refresh-token"); + let disk_auth = AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(disk_tokens.clone()), + last_refresh: Some(fresh_refresh), + }; + save_auth( + ctx.codex_home.path(), + &disk_auth, + AuthCredentialsStoreMode::File, + )?; + + let cached_auth = ctx + .auth_manager + .auth() + .await + .context("auth should reload from disk")?; + let cached = cached_auth + .get_token_data() + .context("token data should reload from disk")?; + assert_eq!(cached, disk_tokens); + + let stored = ctx.load_auth()?; + assert_eq!(stored, disk_auth); + + let requests = server.received_requests().await.unwrap_or_default(); + assert!(requests.is_empty(), "expected no refresh token requests"); + + Ok(()) +} + +#[serial_test::serial(auth_refresh)] +#[tokio::test] +async fn auth_reloads_disk_auth_without_calling_expired_refresh_token() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "error": { + "code": "refresh_token_expired" + } + }))) + .expect(0) + .mount(&server) + .await; + + let ctx = RefreshTokenTestContext::new(&server)?; + let stale_refresh = Utc::now() - Duration::days(9); + let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN); + let initial_auth = AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(initial_tokens), + last_refresh: Some(stale_refresh), + }; + ctx.write_auth(&initial_auth)?; + + let fresh_refresh = Utc::now() - Duration::days(1); + let disk_tokens = build_tokens("disk-access-token", "disk-refresh-token"); + let disk_auth = AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(disk_tokens.clone()), + last_refresh: Some(fresh_refresh), + }; + save_auth( + ctx.codex_home.path(), + &disk_auth, + AuthCredentialsStoreMode::File, + )?; + + let cached_auth = ctx + .auth_manager + .auth() + .await + .context("auth should reload from disk")?; + let cached = cached_auth + .get_token_data() + .context("token data should reload from disk")?; + assert_eq!(cached, disk_tokens); + + let stored = ctx.load_auth()?; + assert_eq!(stored, disk_auth); + + server.verify().await; + Ok(()) +} + #[serial_test::serial(auth_refresh)] #[tokio::test] async fn refresh_token_returns_permanent_error_for_expired_refresh_token() -> Result<()> { diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 941249cca4..b9e4f05b36 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -1672,8 +1672,6 @@ async fn code_mode_exit_stops_script_immediately() -> Result<()> { &server, "use exec to stop script early with exit helper", r#" -import { exit, text } from "@openai/code_mode"; - text("before"); exit(); text("after"); @@ -2129,6 +2127,7 @@ text(JSON.stringify(Object.getOwnPropertyNames(globalThis).sort())); "SuppressedError", "Symbol", "SyntaxError", + "Temporal", "TypeError", "URIError", "Uint16Array", @@ -2141,7 +2140,6 @@ text(JSON.stringify(Object.getOwnPropertyNames(globalThis).sort())); "WebAssembly", "__codexContentItems", "add_content", - "console", "decodeURI", "decodeURIComponent", "encodeURI", @@ -2277,20 +2275,13 @@ async fn code_mode_can_call_hidden_dynamic_tools() -> Result<()> { false, ) .await?; - let test = TestCodex { - home: base_test.home, - cwd: base_test.cwd, - codex: new_thread.thread, - session_configured: new_thread.session_configured, - config: base_test.config, - thread_manager: base_test.thread_manager, - }; + let mut test = base_test; + test.codex = new_thread.thread; + test.session_configured = new_thread.session_configured; let code = r#" -import { ALL_TOOLS, hidden_dynamic_tool } from "tools.js"; - const tool = ALL_TOOLS.find(({ name }) => name === "hidden_dynamic_tool"); -const out = await hidden_dynamic_tool({ city: "Paris" }); +const out = await tools.hidden_dynamic_tool({ city: "Paris" }); text( JSON.stringify({ name: tool?.name ?? null, diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 3ef5403653..5f7e50f061 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -103,6 +103,7 @@ mod prompt_caching; mod quota_exceeded; mod read_file; mod realtime_conversation; +mod remote_env; mod remote_models; mod request_compression; #[cfg(not(target_os = "windows"))] diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index cdf69acdc0..2838233e7f 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -493,6 +493,7 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { let expected = DeveloperInstructions::from_policy( &sandbox_policy, AskForApproval::OnRequest, + test.config.approvals_reviewer, &Policy::empty(), test.config.cwd.as_path(), false, diff --git a/codex-rs/core/tests/suite/personality_migration.rs b/codex-rs/core/tests/suite/personality_migration.rs index adbd86cb23..0a8dd61d9c 100644 --- a/codex-rs/core/tests/suite/personality_migration.rs +++ b/codex-rs/core/tests/suite/personality_migration.rs @@ -66,6 +66,7 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R originator: "test_originator".to_string(), cli_version: "test_version".to_string(), source: SessionSource::Cli, + agent_path: None, agent_nickname: None, agent_role: None, model_provider: None, @@ -110,6 +111,7 @@ async fn write_rollout_with_meta_only(dir: &Path, thread_id: ThreadId) -> io::Re originator: "test_originator".to_string(), cli_version: "test_version".to_string(), source: SessionSource::Cli, + agent_path: None, agent_nickname: None, agent_role: None, model_provider: None, diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs new file mode 100644 index 0000000000..0dd7718d3a --- /dev/null +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -0,0 +1,57 @@ +use anyhow::Result; +use codex_exec_server::RemoveOptions; +use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::get_remote_test_env; +use core_test_support::test_codex::test_env; +use pretty_assertions::assert_eq; +use std::path::PathBuf; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_test_env_can_connect_and_use_filesystem() -> Result<()> { + let Some(_remote_env) = get_remote_test_env() else { + return Ok(()); + }; + + let test_env = test_env().await?; + let file_system = test_env.environment().get_filesystem(); + + let file_path = remote_test_file_path(); + let file_path_abs = absolute_path(file_path.clone())?; + let payload = b"remote-test-env-ok".to_vec(); + + file_system + .write_file(&file_path_abs, payload.clone()) + .await?; + let actual = file_system.read_file(&file_path_abs).await?; + assert_eq!(actual, payload); + + file_system + .remove( + &file_path_abs, + RemoveOptions { + recursive: false, + force: true, + }, + ) + .await?; + + Ok(()) +} + +fn absolute_path(path: PathBuf) -> Result { + AbsolutePathBuf::try_from(path.clone()) + .map_err(|err| anyhow::anyhow!("invalid absolute path {}: {err}", path.display())) +} + +fn remote_test_file_path() -> PathBuf { + let nanos = match SystemTime::now().duration_since(UNIX_EPOCH) { + Ok(duration) => duration.as_nanos(), + Err(_) => 0, + }; + PathBuf::from(format!( + "/tmp/codex-remote-test-env-{}-{nanos}.txt", + std::process::id() + )) +} diff --git a/codex-rs/core/tests/suite/shell_command.rs b/codex-rs/core/tests/suite/shell_command.rs index 9a128b6a80..6eda4a7c36 100644 --- a/codex-rs/core/tests/suite/shell_command.rs +++ b/codex-rs/core/tests/suite/shell_command.rs @@ -1,7 +1,6 @@ use std::time::Duration; use anyhow::Result; -use codex_features::Feature; use core_test_support::assert_regex_match; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -251,16 +250,7 @@ async fn shell_command_times_out_with_timeout_ms() -> anyhow::Result<()> { async fn unicode_output(login: bool) -> anyhow::Result<()> { skip_if_no_network!(Ok(())); - #[allow(clippy::expect_used)] - let harness = shell_command_harness_with(|builder| { - builder.with_model("gpt-5.2").with_config(|config| { - config - .features - .enable(Feature::PowershellUtf8) - .expect("test config should allow feature update"); - }) - }) - .await?; + let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.2")).await?; // We use a child process on windows instead of a direct builtin like 'echo' to ensure that Powershell // config is actually being set correctly. @@ -286,16 +276,7 @@ async fn unicode_output(login: bool) -> anyhow::Result<()> { async fn unicode_output_with_newlines(login: bool) -> anyhow::Result<()> { skip_if_no_network!(Ok(())); - #[allow(clippy::expect_used)] - let harness = shell_command_harness_with(|builder| { - builder.with_model("gpt-5.2").with_config(|config| { - config - .features - .enable(Feature::PowershellUtf8) - .expect("test config should allow feature update"); - }) - }) - .await?; + let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.2")).await?; let call_id = "unicode_output"; mount_shell_responses_with_timeout( diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 2801f1ab1a..248ada02cc 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -141,6 +141,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> { originator: "test".to_string(), cli_version: "test".to_string(), source: SessionSource::default(), + agent_path: None, agent_nickname: None, agent_role: None, model_provider: None, diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 1e6073be09..7252d9a6b6 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -159,7 +159,9 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { let call_id = "uexec-apply-patch"; let args = json!({ "cmd": command, - "yield_time_ms": 250, + // The intercepted apply_patch path spawns a helper process, which can + // take longer than a tiny unified-exec yield deadline on CI. + "yield_time_ms": 5_000, }); let responses = vec![ diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 6c6ef7cdc1..efc2e53324 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -3,6 +3,7 @@ use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_core::CodexAuth; +use codex_exec_server::CreateDirectoryOptions; use codex_features::Feature; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::openai_models::ConfigShellToolType; @@ -32,12 +33,16 @@ use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use core_test_support::wait_for_event_with_timeout; +use image::DynamicImage; use image::GenericImageView; use image::ImageBuffer; use image::Rgba; use image::load_from_memory; use pretty_assertions::assert_eq; use serde_json::Value; +use std::io::Cursor; +use std::path::Path; +use std::path::PathBuf; use tokio::time::Duration; use wiremock::BodyPrintLimit; use wiremock::MockServer; @@ -73,26 +78,78 @@ fn find_image_message(body: &Value) -> Option<&Value> { image_messages(body).into_iter().next() } +fn absolute_path(path: &Path) -> anyhow::Result { + codex_utils_absolute_path::AbsolutePathBuf::try_from(path.to_path_buf()) + .map_err(|err| anyhow::anyhow!("invalid absolute path {}: {err}", path.display())) +} + +fn png_bytes(width: u32, height: u32, rgba: [u8; 4]) -> anyhow::Result> { + let image = ImageBuffer::from_pixel(width, height, Rgba(rgba)); + let mut cursor = Cursor::new(Vec::new()); + DynamicImage::ImageRgba8(image).write_to(&mut cursor, image::ImageFormat::Png)?; + Ok(cursor.into_inner()) +} + +async fn create_workspace_directory(test: &TestCodex, rel_path: &str) -> anyhow::Result { + let abs_path = test.config.cwd.join(rel_path); + test.fs() + .create_directory( + &absolute_path(&abs_path)?, + CreateDirectoryOptions { recursive: true }, + ) + .await?; + Ok(abs_path) +} + +async fn write_workspace_file( + test: &TestCodex, + rel_path: &str, + contents: Vec, +) -> anyhow::Result { + let abs_path = test.config.cwd.join(rel_path); + if let Some(parent) = abs_path.parent() { + test.fs() + .create_directory( + &absolute_path(parent)?, + CreateDirectoryOptions { recursive: true }, + ) + .await?; + } + test.fs() + .write_file(&absolute_path(&abs_path)?, contents) + .await?; + Ok(abs_path) +} + +async fn write_workspace_png( + test: &TestCodex, + rel_path: &str, + width: u32, + height: u32, + rgba: [u8; 4], +) -> anyhow::Result { + write_workspace_file(test, rel_path, png_bytes(width, height, rgba)?).await +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; + let mut builder = test_codex(); + let test = builder.build_remote_aware(&server).await?; let TestCodex { codex, - cwd, + config, session_configured, .. - } = test_codex().build(&server).await?; + } = &test; - let rel_path = "user-turn/example.png"; - let abs_path = cwd.path().join(rel_path); - if let Some(parent) = abs_path.parent() { - std::fs::create_dir_all(parent)?; - } let original_width = 2304; let original_height = 864; + let local_image_dir = tempfile::tempdir()?; + let abs_path = local_image_dir.path().join("example.png"); let image = ImageBuffer::from_pixel(original_width, original_height, Rgba([20u8, 40, 60, 255])); image.save(&abs_path)?; @@ -111,7 +168,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { path: abs_path.clone(), }], final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), + cwd: config.cwd.clone(), approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, @@ -124,7 +181,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { .await?; wait_for_event_with_timeout( - &codex, + codex, |event| matches!(event, EventMsg::TurnComplete(_)), // Empirically, image attachment can be slow under Bazel/RBE. Duration::from_secs(10), @@ -171,23 +228,28 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - + let mut builder = test_codex(); + let test = builder.build_remote_aware(&server).await?; let TestCodex { codex, - cwd, session_configured, + config, .. - } = test_codex().build(&server).await?; + } = &test; + let cwd = config.cwd.clone(); let rel_path = "assets/example.png"; - let abs_path = cwd.path().join(rel_path); - if let Some(parent) = abs_path.parent() { - std::fs::create_dir_all(parent)?; - } + let abs_path = cwd.join(rel_path); let original_width = 2304; let original_height = 864; - let image = ImageBuffer::from_pixel(original_width, original_height, Rgba([255u8, 0, 0, 255])); - image.save(&abs_path)?; + write_workspace_png( + &test, + rel_path, + original_width, + original_height, + [255u8, 0, 0, 255], + ) + .await?; let call_id = "view-image-call"; let arguments = serde_json::json!({ "path": rel_path }).to_string(); @@ -214,7 +276,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), + cwd: cwd.clone(), approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, @@ -228,7 +290,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { let mut tool_event = None; wait_for_event_with_timeout( - &codex, + codex, |event| match event { EventMsg::ViewImageToolCall(_) => { tool_event = Some(event.clone()); @@ -309,22 +371,25 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 .enable(Feature::ImageDetailOriginal) .expect("test config should allow feature update"); }); + let test = builder.build_remote_aware(&server).await?; let TestCodex { codex, - cwd, + config, session_configured, .. - } = builder.build(&server).await?; + } = &test; let rel_path = "assets/original-example.png"; - let abs_path = cwd.path().join(rel_path); - if let Some(parent) = abs_path.parent() { - std::fs::create_dir_all(parent)?; - } let original_width = 2304; let original_height = 864; - let image = ImageBuffer::from_pixel(original_width, original_height, Rgba([0u8, 80, 255, 255])); - image.save(&abs_path)?; + write_workspace_png( + &test, + rel_path, + original_width, + original_height, + [0u8, 80, 255, 255], + ) + .await?; let call_id = "view-image-original"; let arguments = serde_json::json!({ "path": rel_path, "detail": "original" }).to_string(); @@ -351,7 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), + cwd: config.cwd.clone(), approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, @@ -364,7 +429,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 .await?; wait_for_event_with_timeout( - &codex, + codex, |event| matches!(event, EventMsg::TurnComplete(_)), Duration::from_secs(10), ) @@ -413,20 +478,16 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho .enable(Feature::ImageDetailOriginal) .expect("test config should allow feature update"); }); + let test = builder.build_remote_aware(&server).await?; let TestCodex { codex, - cwd, + config, session_configured, .. - } = builder.build(&server).await?; + } = &test; let rel_path = "assets/unsupported-detail.png"; - let abs_path = cwd.path().join(rel_path); - if let Some(parent) = abs_path.parent() { - std::fs::create_dir_all(parent)?; - } - let image = ImageBuffer::from_pixel(256, 128, Rgba([0u8, 80, 255, 255])); - image.save(&abs_path)?; + write_workspace_png(&test, rel_path, 256, 128, [0u8, 80, 255, 255]).await?; let call_id = "view-image-unsupported-detail"; let arguments = serde_json::json!({ "path": rel_path, "detail": "low" }).to_string(); @@ -453,7 +514,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), + cwd: config.cwd.clone(), approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, @@ -465,7 +526,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho }) .await?; - wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; let req = mock.single_request(); let body_with_tool_output = req.body_json(); @@ -499,22 +560,25 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { .enable(Feature::ImageDetailOriginal) .expect("test config should allow feature update"); }); + let test = builder.build_remote_aware(&server).await?; let TestCodex { codex, - cwd, + config, session_configured, .. - } = builder.build(&server).await?; + } = &test; let rel_path = "assets/null-detail.png"; - let abs_path = cwd.path().join(rel_path); - if let Some(parent) = abs_path.parent() { - std::fs::create_dir_all(parent)?; - } let original_width = 2304; let original_height = 864; - let image = ImageBuffer::from_pixel(original_width, original_height, Rgba([0u8, 80, 255, 255])); - image.save(&abs_path)?; + write_workspace_png( + &test, + rel_path, + original_width, + original_height, + [0u8, 80, 255, 255], + ) + .await?; let call_id = "view-image-null-detail"; let arguments = serde_json::json!({ "path": rel_path, "detail": null }).to_string(); @@ -541,7 +605,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), + cwd: config.cwd.clone(), approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, @@ -553,7 +617,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { }) .await?; - wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; let req = mock.single_request(); let function_output = req.function_call_output(call_id); @@ -595,22 +659,25 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a .enable(Feature::ImageDetailOriginal) .expect("test config should allow feature update"); }); + let test = builder.build_remote_aware(&server).await?; let TestCodex { codex, - cwd, + config, session_configured, .. - } = builder.build(&server).await?; + } = &test; let rel_path = "assets/original-example-lower-model.png"; - let abs_path = cwd.path().join(rel_path); - if let Some(parent) = abs_path.parent() { - std::fs::create_dir_all(parent)?; - } let original_width = 2304; let original_height = 864; - let image = ImageBuffer::from_pixel(original_width, original_height, Rgba([0u8, 80, 255, 255])); - image.save(&abs_path)?; + write_workspace_png( + &test, + rel_path, + original_width, + original_height, + [0u8, 80, 255, 255], + ) + .await?; let call_id = "view-image-original-lower-model"; let arguments = serde_json::json!({ "path": rel_path }).to_string(); @@ -637,7 +704,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), + cwd: config.cwd.clone(), approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, @@ -650,7 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a .await?; wait_for_event_with_timeout( - &codex, + codex, |event| matches!(event, EventMsg::TurnComplete(_)), Duration::from_secs(10), ) @@ -702,22 +769,25 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat .enable(Feature::ImageDetailOriginal) .expect("test config should allow feature update"); }); + let test = builder.build_remote_aware(&server).await?; let TestCodex { codex, - cwd, + config, session_configured, .. - } = builder.build(&server).await?; + } = &test; let rel_path = "assets/original-example-capability-only.png"; - let abs_path = cwd.path().join(rel_path); - if let Some(parent) = abs_path.parent() { - std::fs::create_dir_all(parent)?; - } let original_width = 2304; let original_height = 864; - let image = ImageBuffer::from_pixel(original_width, original_height, Rgba([0u8, 80, 255, 255])); - image.save(&abs_path)?; + write_workspace_png( + &test, + rel_path, + original_width, + original_height, + [0u8, 80, 255, 255], + ) + .await?; let call_id = "view-image-capability-only"; let arguments = serde_json::json!({ "path": rel_path }).to_string(); @@ -744,7 +814,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), + cwd: config.cwd.clone(), approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, @@ -757,7 +827,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat .await?; wait_for_event_with_timeout( - &codex, + codex, |event| matches!(event, EventMsg::TurnComplete(_)), Duration::from_secs(10), ) @@ -1019,16 +1089,17 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { let server = start_mock_server().await; + let mut builder = test_codex(); + let test = builder.build_remote_aware(&server).await?; let TestCodex { codex, - cwd, + config, session_configured, .. - } = test_codex().build(&server).await?; + } = &test; let rel_path = "assets"; - let abs_path = cwd.path().join(rel_path); - std::fs::create_dir_all(&abs_path)?; + let abs_path = create_workspace_directory(&test, rel_path).await?; let call_id = "view-image-directory"; let arguments = serde_json::json!({ "path": rel_path }).to_string(); @@ -1055,7 +1126,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), + cwd: config.cwd.clone(), approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, @@ -1067,7 +1138,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { }) .await?; - wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; let req = mock.single_request(); let body_with_tool_output = req.body_json(); @@ -1092,19 +1163,18 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { let server = start_mock_server().await; + let mut builder = test_codex(); + let test = builder.build_remote_aware(&server).await?; let TestCodex { codex, - cwd, + config, session_configured, .. - } = test_codex().build(&server).await?; + } = &test; let rel_path = "assets/example.json"; - let abs_path = cwd.path().join(rel_path); - if let Some(parent) = abs_path.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::write(&abs_path, br#"{ "message": "hello" }"#)?; + let abs_path = + write_workspace_file(&test, rel_path, br#"{ "message": "hello" }"#.to_vec()).await?; let call_id = "view-image-non-image"; let arguments = serde_json::json!({ "path": rel_path }).to_string(); @@ -1131,7 +1201,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), + cwd: config.cwd.clone(), approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, @@ -1143,7 +1213,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { }) .await?; - wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; let request = mock.single_request(); assert!( @@ -1174,15 +1244,17 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { let server = start_mock_server().await; + let mut builder = test_codex(); + let test = builder.build_remote_aware(&server).await?; let TestCodex { codex, - cwd, + config, session_configured, .. - } = test_codex().build(&server).await?; + } = &test; let rel_path = "missing/example.png"; - let abs_path = cwd.path().join(rel_path); + let abs_path = config.cwd.join(rel_path); let call_id = "view-image-missing"; let arguments = serde_json::json!({ "path": rel_path }).to_string(); @@ -1209,7 +1281,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), + cwd: config.cwd.clone(), approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, @@ -1221,7 +1293,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { }) .await?; - wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; let req = mock.single_request(); let body_with_tool_output = req.body_json(); @@ -1298,21 +1370,16 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an ) .await; - let TestCodex { codex, cwd, .. } = test_codex() + let mut builder = test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(|config| { config.model = Some(model_slug.to_string()); - }) - .build(&server) - .await?; + }); + let test = builder.build_remote_aware(&server).await?; + let TestCodex { codex, config, .. } = &test; let rel_path = "assets/example.png"; - let abs_path = cwd.path().join(rel_path); - if let Some(parent) = abs_path.parent() { - std::fs::create_dir_all(parent)?; - } - let image = ImageBuffer::from_pixel(20, 20, Rgba([255u8, 0, 0, 255])); - image.save(&abs_path)?; + write_workspace_png(&test, rel_path, 20, 20, [255u8, 0, 0, 255]).await?; let call_id = "view-image-unsupported-model"; let arguments = serde_json::json!({ "path": rel_path }).to_string(); @@ -1336,7 +1403,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), + cwd: config.cwd.clone(), approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::DangerFullAccess, model: model_slug.to_string(), @@ -1348,7 +1415,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an }) .await?; - wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; let output_text = mock .single_request() @@ -1390,20 +1457,17 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> let completion_mock = responses::mount_sse_once(&server, success_response).await; + let mut builder = test_codex(); + let test = builder.build_remote_aware(&server).await?; let TestCodex { codex, - cwd, + config, session_configured, .. - } = test_codex().build(&server).await?; + } = &test; let rel_path = "assets/poisoned.png"; - let abs_path = cwd.path().join(rel_path); - if let Some(parent) = abs_path.parent() { - std::fs::create_dir_all(parent)?; - } - let image = ImageBuffer::from_pixel(1024, 512, Rgba([10u8, 20, 30, 255])); - image.save(&abs_path)?; + let abs_path = write_workspace_png(&test, rel_path, 1024, 512, [10u8, 20, 30, 255]).await?; let session_model = session_configured.model.clone(); @@ -1413,7 +1477,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> path: abs_path.clone(), }], final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), + cwd: config.cwd.clone(), approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, diff --git a/codex-rs/deny.toml b/codex-rs/deny.toml index 29bf6c5880..bad1c8f92e 100644 --- a/codex-rs/deny.toml +++ b/codex-rs/deny.toml @@ -73,8 +73,6 @@ ignore = [ { id = "RUSTSEC-2024-0388", reason = "derivative is unmaintained; pulled in via starlark v0.13.0 used by execpolicy/cli/core; no fixed release yet" }, { id = "RUSTSEC-2025-0057", reason = "fxhash is unmaintained; pulled in via starlark_map/starlark v0.13.0 used by execpolicy/cli/core; no fixed release yet" }, { id = "RUSTSEC-2024-0436", reason = "paste is unmaintained; pulled in via ratatui/rmcp/starlark used by tui/execpolicy; no fixed release yet" }, - # TODO(joshka, nornagon): remove this exception when once we update the ratatui fork to a version that uses lru 0.13+. - { id = "RUSTSEC-2026-0002", reason = "lru 0.12.5 is pulled in via ratatui fork; cannot upgrade until the fork is updated" }, # TODO(fcoury): remove this exception when syntect drops yaml-rust and bincode, or updates to versions that have fixed the vulnerabilities. { id = "RUSTSEC-2024-0320", reason = "yaml-rust is unmaintained; pulled in via syntect v5.3.0 used by codex-tui for syntax highlighting; no fixed release yet" }, { id = "RUSTSEC-2025-0141", reason = "bincode is unmaintained; pulled in via syntect v5.3.0 used by codex-tui for syntax highlighting; no fixed release yet" }, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index f648a63952..c10b86f696 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -669,7 +669,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { // Print the effective configuration and initial request so users can see what Codex // is using. event_processor.print_config_summary(&config, &prompt_summary, &session_configured); - if !json_mode && let Some(message) = codex_core::config::missing_system_bwrap_warning() { + if !json_mode && let Some(message) = codex_core::config::system_bwrap_warning() { let _ = event_processor.process_event(Event { id: String::new(), msg: EventMsg::Warning(codex_protocol::protocol::WarningEvent { message }), diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index b1cc5f0dc1..aa1125c8d3 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -132,12 +132,12 @@ pub enum Feature { ChildAgentsMd, /// Allow the model to request `detail: "original"` image outputs on supported models. ImageDetailOriginal, - /// Enforce UTF8 output in Powershell. - PowershellUtf8, /// Compress request bodies (zstd) when sending streaming requests to codex-backend. EnableRequestCompression, /// Enable collab tools. Collab, + /// Enable task-path-based multi-agent routing. + MultiAgentV2, /// Enable CSV-backed agent job tools. SpawnCsv, /// Enable apps. @@ -689,18 +689,6 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Removed, default_enabled: false, }, - FeatureSpec { - id: Feature::PowershellUtf8, - key: "powershell_utf8", - #[cfg(windows)] - stage: Stage::Stable, - #[cfg(windows)] - default_enabled: true, - #[cfg(not(windows))] - stage: Stage::UnderDevelopment, - #[cfg(not(windows))] - default_enabled: false, - }, FeatureSpec { id: Feature::EnableRequestCompression, key: "enable_request_compression", @@ -713,6 +701,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::MultiAgentV2, + key: "multi_agent_v2", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::SpawnCsv, key: "enable_fanout", diff --git a/codex-rs/linux-sandbox/README.md b/codex-rs/linux-sandbox/README.md index 3fde7d9738..ac34fb4edc 100644 --- a/codex-rs/linux-sandbox/README.md +++ b/codex-rs/linux-sandbox/README.md @@ -8,19 +8,23 @@ This crate is responsible for producing: - this should also be true of the `codex` multitool CLI On Linux, the bubblewrap pipeline prefers the system `/usr/bin/bwrap` whenever -it is available. If `/usr/bin/bwrap` is missing, the helper still falls back to +it is available and supports the required argv-rewrite flags. If `/usr/bin/bwrap` +is missing or too old to support the required flags, the helper falls back to the vendored bubblewrap path compiled into this binary. -Codex also surfaces a startup warning when `/usr/bin/bwrap` is missing so users -know it is falling back to the vendored helper. +Codex also surfaces a startup warning when `/usr/bin/bwrap` is missing or too +old to support the required flags so users know it is falling back to the +vendored helper. **Current Behavior** - Legacy `SandboxPolicy` / `sandbox_mode` configs remain supported. - Bubblewrap is the default filesystem sandbox pipeline. -- If `/usr/bin/bwrap` is present, the helper uses it. -- If `/usr/bin/bwrap` is missing, the helper falls back to the vendored - bubblewrap path. -- If `/usr/bin/bwrap` is missing, Codex also surfaces a startup warning instead - of printing directly from the sandbox helper. +- If `/usr/bin/bwrap` is present and supports the required argv-rewrite flags, + the helper uses it. +- If `/usr/bin/bwrap` is missing or too old to support the required flags, the + helper falls back to the vendored bubblewrap path. +- If `/usr/bin/bwrap` is missing or too old to support the required flags, + Codex also surfaces a startup warning instead of printing directly from the + sandbox helper. - Legacy Landlock + mount protections remain available as an explicit legacy fallback path. - Set `features.use_legacy_landlock = true` (or CLI `-c use_legacy_landlock=true`) diff --git a/codex-rs/linux-sandbox/src/launcher.rs b/codex-rs/linux-sandbox/src/launcher.rs index 37a860e085..7d7e040844 100644 --- a/codex-rs/linux-sandbox/src/launcher.rs +++ b/codex-rs/linux-sandbox/src/launcher.rs @@ -4,6 +4,8 @@ use std::os::fd::AsRawFd; use std::os::raw::c_char; use std::os::unix::ffi::OsStrExt; use std::path::Path; +use std::process::Command; +use std::sync::OnceLock; use crate::vendored_bwrap::exec_vendored_bwrap; use codex_utils_absolute_path::AbsolutePathBuf; @@ -24,17 +26,41 @@ pub(crate) fn exec_bwrap(argv: Vec, preserved_files: Vec) -> ! { } fn preferred_bwrap_launcher() -> BubblewrapLauncher { - if !Path::new(SYSTEM_BWRAP_PATH).is_file() { + static LAUNCHER: OnceLock = OnceLock::new(); + LAUNCHER + .get_or_init(|| preferred_bwrap_launcher_for_path(Path::new(SYSTEM_BWRAP_PATH))) + .clone() +} + +fn preferred_bwrap_launcher_for_path(system_bwrap_path: &Path) -> BubblewrapLauncher { + if !system_bwrap_supports_argv0(system_bwrap_path) { return BubblewrapLauncher::Vendored; } - let system_bwrap_path = match AbsolutePathBuf::from_absolute_path(SYSTEM_BWRAP_PATH) { + let system_bwrap_path = match AbsolutePathBuf::from_absolute_path(system_bwrap_path) { Ok(path) => path, - Err(err) => panic!("failed to normalize system bubblewrap path {SYSTEM_BWRAP_PATH}: {err}"), + Err(err) => panic!( + "failed to normalize system bubblewrap path {}: {err}", + system_bwrap_path.display() + ), }; BubblewrapLauncher::System(system_bwrap_path) } +fn system_bwrap_supports_argv0(system_bwrap_path: &Path) -> bool { + // bubblewrap added `--argv0` in v0.9.0: + // https://github.com/containers/bubblewrap/releases/tag/v0.9.0 + // Older distro packages (for example Ubuntu 20.04/22.04) ship builds that + // reject `--argv0`, so prefer the vendored build in that case. + let output = match Command::new(system_bwrap_path).arg("--help").output() { + Ok(output) => output, + Err(_) => return false, + }; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + stdout.contains("--argv0") || stderr.contains("--argv0") +} + fn exec_system_bwrap( program: &AbsolutePathBuf, argv: Vec, @@ -100,7 +126,57 @@ fn clear_cloexec(fd: libc::c_int) { mod tests { use super::*; use pretty_assertions::assert_eq; + use std::fs; + use std::os::unix::fs::PermissionsExt; use tempfile::NamedTempFile; + use tempfile::TempPath; + + #[test] + fn prefers_system_bwrap_when_help_lists_argv0() { + let fake_bwrap = write_fake_bwrap( + r#"#!/bin/sh +if [ "$1" = "--help" ]; then + echo ' --argv0 PROGRAM' + exit 0 +fi +exit 1 +"#, + ); + let fake_bwrap_path: &Path = fake_bwrap.as_ref(); + let expected = AbsolutePathBuf::from_absolute_path(fake_bwrap_path).expect("absolute"); + + assert_eq!( + preferred_bwrap_launcher_for_path(fake_bwrap_path), + BubblewrapLauncher::System(expected) + ); + } + + #[test] + fn falls_back_to_vendored_when_system_bwrap_lacks_argv0() { + let fake_bwrap = write_fake_bwrap( + r#"#!/bin/sh +if [ "$1" = "--help" ]; then + echo 'usage: bwrap [OPTION...] COMMAND' + exit 0 +fi +exit 1 +"#, + ); + let fake_bwrap_path: &Path = fake_bwrap.as_ref(); + + assert_eq!( + preferred_bwrap_launcher_for_path(fake_bwrap_path), + BubblewrapLauncher::Vendored + ); + } + + #[test] + fn falls_back_to_vendored_when_system_bwrap_is_missing() { + assert_eq!( + preferred_bwrap_launcher_for_path(Path::new("/definitely/not/a/bwrap")), + BubblewrapLauncher::Vendored + ); + } #[test] fn preserved_files_are_made_inheritable_for_system_exec() { @@ -131,4 +207,13 @@ mod tests { } flags } + + fn write_fake_bwrap(contents: &str) -> TempPath { + // Linux rejects exec-ing a file that is still open for writing. + let path = NamedTempFile::new().expect("temp file").into_temp_path(); + fs::write(&path, contents).expect("write fake bwrap"); + let permissions = fs::Permissions::from_mode(0o755); + fs::set_permissions(&path, permissions).expect("chmod fake bwrap"); + path + } } diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 1e4cd06d3a..31860cd585 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -1090,10 +1090,13 @@ impl AuthManager { } /// Current cached auth (clone). May be `None` if not logged in or load failed. - /// Refreshes cached ChatGPT tokens if they are stale before returning. + /// For stale managed ChatGPT auth, first performs a guarded reload and then + /// refreshes only if the on-disk auth is unchanged. pub async fn auth(&self) -> Option { let auth = self.auth_cached()?; - if let Err(err) = self.refresh_if_stale(&auth).await { + if Self::is_stale_for_proactive_refresh(&auth) + && let Err(err) = self.refresh_token().await + { tracing::error!("Failed to refresh token: {}", err); return Some(auth); } @@ -1320,30 +1323,21 @@ impl AuthManager { self.auth_cached().as_ref().map(CodexAuth::auth_mode) } - async fn refresh_if_stale(&self, auth: &CodexAuth) -> Result { + fn is_stale_for_proactive_refresh(auth: &CodexAuth) -> bool { let chatgpt_auth = match auth { CodexAuth::Chatgpt(chatgpt_auth) => chatgpt_auth, - _ => return Ok(false), + _ => return false, }; let auth_dot_json = match chatgpt_auth.current_auth_json() { Some(auth_dot_json) => auth_dot_json, - None => return Ok(false), - }; - let tokens = match auth_dot_json.tokens { - Some(tokens) => tokens, - None => return Ok(false), + None => return false, }; let last_refresh = match auth_dot_json.last_refresh { Some(last_refresh) => last_refresh, - None => return Ok(false), + None => return false, }; - if last_refresh >= Utc::now() - chrono::Duration::days(TOKEN_REFRESH_INTERVAL) { - return Ok(false); - } - self.refresh_and_persist_chatgpt_token(chatgpt_auth, tokens.refresh_token) - .await?; - Ok(true) + last_refresh < Utc::now() - chrono::Duration::days(TOKEN_REFRESH_INTERVAL) } async fn refresh_external_auth( diff --git a/codex-rs/protocol/src/agent_path.rs b/codex-rs/protocol/src/agent_path.rs new file mode 100644 index 0000000000..f0b99438d0 --- /dev/null +++ b/codex-rs/protocol/src/agent_path.rs @@ -0,0 +1,223 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::fmt; +use std::ops::Deref; +use std::str::FromStr; +use ts_rs::TS; + +#[derive( + Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema, TS, +)] +#[serde(try_from = "String", into = "String")] +#[schemars(with = "String")] +#[ts(type = "string")] +pub struct AgentPath(String); + +impl AgentPath { + pub const ROOT: &str = "/root"; + const ROOT_SEGMENT: &str = "root"; + + pub fn root() -> Self { + Self(Self::ROOT.to_string()) + } + + pub fn from_string(path: String) -> Result { + validate_absolute_path(path.as_str())?; + Ok(Self(path)) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + pub fn is_root(&self) -> bool { + self.as_str() == Self::ROOT + } + + pub fn name(&self) -> &str { + if self.is_root() { + return Self::ROOT_SEGMENT; + } + self.as_str() + .rsplit('/') + .next() + .filter(|segment| !segment.is_empty()) + .unwrap_or(Self::ROOT_SEGMENT) + } + + pub fn join(&self, agent_name: &str) -> Result { + validate_agent_name(agent_name)?; + Self::from_string(format!("{self}/{agent_name}")) + } + + pub fn resolve(&self, reference: &str) -> Result { + if reference.is_empty() { + return Err("agent path must not be empty".to_string()); + } + if reference == Self::ROOT { + return Ok(Self::root()); + } + if reference.starts_with('/') { + return Self::try_from(reference); + } + + validate_relative_reference(reference)?; + Self::from_string(format!("{self}/{reference}")) + } +} + +impl TryFrom for AgentPath { + type Error = String; + + fn try_from(value: String) -> Result { + Self::from_string(value) + } +} + +impl TryFrom<&str> for AgentPath { + type Error = String; + + fn try_from(value: &str) -> Result { + Self::from_string(value.to_string()) + } +} + +impl From for String { + fn from(value: AgentPath) -> Self { + value.0 + } +} + +impl FromStr for AgentPath { + type Err = String; + + fn from_str(s: &str) -> Result { + Self::try_from(s) + } +} + +impl AsRef for AgentPath { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Deref for AgentPath { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.as_str() + } +} + +impl fmt::Display for AgentPath { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +fn validate_agent_name(agent_name: &str) -> Result<(), String> { + if agent_name.is_empty() { + return Err("agent_name must not be empty".to_string()); + } + if agent_name == AgentPath::ROOT_SEGMENT { + return Err("agent_name `root` is reserved".to_string()); + } + if agent_name == "." || agent_name == ".." { + return Err(format!("agent_name `{agent_name}` is reserved")); + } + if agent_name.contains('/') { + return Err("agent_name must not contain `/`".to_string()); + } + if !agent_name + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_') + { + return Err( + "agent_name must use only lowercase letters, digits, and underscores".to_string(), + ); + } + Ok(()) +} + +fn validate_absolute_path(path: &str) -> Result<(), String> { + let Some(stripped) = path.strip_prefix('/') else { + return Err("absolute agent paths must start with `/root`".to_string()); + }; + let mut segments = stripped.split('/'); + let Some(root) = segments.next() else { + return Err("absolute agent path must not be empty".to_string()); + }; + if root != AgentPath::ROOT_SEGMENT { + return Err("absolute agent paths must start with `/root`".to_string()); + } + if stripped.ends_with('/') { + return Err("absolute agent path must not end with `/`".to_string()); + } + for segment in segments { + validate_agent_name(segment)?; + } + Ok(()) +} + +fn validate_relative_reference(reference: &str) -> Result<(), String> { + if reference.ends_with('/') { + return Err("relative agent path must not end with `/`".to_string()); + } + for segment in reference.split('/') { + validate_agent_name(segment)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::AgentPath; + use pretty_assertions::assert_eq; + + #[test] + fn root_has_expected_name() { + let root = AgentPath::root(); + assert_eq!(root.as_str(), AgentPath::ROOT); + assert_eq!(root.name(), "root"); + assert!(root.is_root()); + } + + #[test] + fn join_builds_child_paths() { + let root = AgentPath::root(); + let child = root.join("researcher").expect("child path"); + assert_eq!(child.as_str(), "/root/researcher"); + assert_eq!(child.name(), "researcher"); + } + + #[test] + fn resolve_supports_relative_and_absolute_references() { + let current = AgentPath::try_from("/root/researcher").expect("path"); + assert_eq!( + current.resolve("worker").expect("relative path"), + AgentPath::try_from("/root/researcher/worker").expect("path") + ); + assert_eq!( + current.resolve("/root/other").expect("absolute path"), + AgentPath::try_from("/root/other").expect("path") + ); + } + + #[test] + fn invalid_names_and_paths_are_rejected() { + assert_eq!( + AgentPath::root().join("BadName"), + Err("agent_name must use only lowercase letters, digits, and underscores".to_string()) + ); + assert_eq!( + AgentPath::try_from("/not-root"), + Err("absolute agent paths must start with `/root`".to_string()) + ); + assert_eq!( + AgentPath::root().resolve("../sibling"), + Err("agent_name `..` is reserved".to_string()) + ); + } +} diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index 10b4c55efe..e618bf8c76 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -1,5 +1,7 @@ pub mod account; +mod agent_path; mod thread_id; +pub use agent_path::AgentPath; pub use thread_id::ThreadId; pub mod approvals; pub mod config_types; diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 4d542c82ad..8ba07ace80 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -9,6 +9,7 @@ use serde::Serialize; use serde::ser::Serializer; use ts_rs::TS; +use crate::config_types::ApprovalsReviewer; use crate::config_types::CollaborationMode; use crate::config_types::SandboxMode; use crate::protocol::AskForApproval; @@ -511,9 +512,10 @@ const APPROVAL_POLICY_UNLESS_TRUSTED: &str = const APPROVAL_POLICY_ON_FAILURE: &str = include_str!("prompts/permissions/approval_policy/on_failure.md"); const APPROVAL_POLICY_ON_REQUEST_RULE: &str = - include_str!("prompts/permissions/approval_policy/on_request_rule.md"); + include_str!("prompts/permissions/approval_policy/on_request.md"); const APPROVAL_POLICY_ON_REQUEST_RULE_REQUEST_PERMISSION: &str = include_str!("prompts/permissions/approval_policy/on_request_rule_request_permission.md"); +const GUARDIAN_SUBAGENT_APPROVAL_SUFFIX: &str = "`approvals_reviewer` is `guardian_subagent`: Sandbox escalations with require_escalated will be reviewed for compliance with the policy. If a rejection happens, you should proceed only with a materially safer alternative, or inform the user of the risk and send a final message to ask for approval."; const SANDBOX_MODE_DANGER_FULL_ACCESS: &str = include_str!("prompts/permissions/sandbox_mode/danger_full_access.md"); @@ -524,6 +526,14 @@ const SANDBOX_MODE_READ_ONLY: &str = include_str!("prompts/permissions/sandbox_m const REALTIME_START_INSTRUCTIONS: &str = include_str!("prompts/realtime/realtime_start.md"); const REALTIME_END_INSTRUCTIONS: &str = include_str!("prompts/realtime/realtime_end.md"); +struct PermissionsPromptConfig<'a> { + approval_policy: AskForApproval, + approvals_reviewer: ApprovalsReviewer, + exec_policy: &'a Policy, + exec_permission_approvals_enabled: bool, + request_permissions_tool_enabled: bool, +} + impl DeveloperInstructions { pub fn new>(text: T) -> Self { Self { text: text.into() } @@ -531,6 +541,7 @@ impl DeveloperInstructions { pub fn from( approval_policy: AskForApproval, + approvals_reviewer: ApprovalsReviewer, exec_policy: &Policy, exec_permission_approvals_enabled: bool, request_permissions_tool_enabled: bool, @@ -574,6 +585,14 @@ impl DeveloperInstructions { ), }; + let text = if approvals_reviewer == ApprovalsReviewer::GuardianSubagent + && approval_policy != AskForApproval::Never + { + format!("{text}\n\n{GUARDIAN_SUBAGENT_APPROVAL_SUFFIX}") + } else { + text + }; + DeveloperInstructions::new(text) } @@ -623,6 +642,7 @@ impl DeveloperInstructions { pub fn from_policy( sandbox_policy: &SandboxPolicy, approval_policy: AskForApproval, + approvals_reviewer: ApprovalsReviewer, exec_policy: &Policy, cwd: &Path, exec_permission_approvals_enabled: bool, @@ -647,11 +667,14 @@ impl DeveloperInstructions { DeveloperInstructions::from_permissions_with_network( sandbox_mode, network_access, - approval_policy, - exec_policy, + PermissionsPromptConfig { + approval_policy, + approvals_reviewer, + exec_policy, + exec_permission_approvals_enabled, + request_permissions_tool_enabled, + }, writable_roots, - exec_permission_approvals_enabled, - request_permissions_tool_enabled, ) } @@ -672,11 +695,8 @@ impl DeveloperInstructions { fn from_permissions_with_network( sandbox_mode: SandboxMode, network_access: NetworkAccess, - approval_policy: AskForApproval, - exec_policy: &Policy, + config: PermissionsPromptConfig<'_>, writable_roots: Option>, - exec_permission_approvals_enabled: bool, - request_permissions_tool_enabled: bool, ) -> Self { let start_tag = DeveloperInstructions::new(""); let end_tag = DeveloperInstructions::new(""); @@ -686,10 +706,11 @@ impl DeveloperInstructions { network_access, )) .concat(DeveloperInstructions::from( - approval_policy, - exec_policy, - exec_permission_approvals_enabled, - request_permissions_tool_enabled, + config.approval_policy, + config.approvals_reviewer, + config.exec_policy, + config.exec_permission_approvals_enabled, + config.request_permissions_tool_enabled, )) .concat(DeveloperInstructions::from_writable_roots(writable_roots)) .concat(end_tag) @@ -1963,11 +1984,14 @@ mod tests { let instructions = DeveloperInstructions::from_permissions_with_network( SandboxMode::WorkspaceWrite, NetworkAccess::Enabled, - AskForApproval::OnRequest, - &Policy::empty(), + PermissionsPromptConfig { + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::User, + exec_policy: &Policy::empty(), + exec_permission_approvals_enabled: false, + request_permissions_tool_enabled: false, + }, None, - false, - false, ); let text = instructions.into_text(); @@ -1994,6 +2018,7 @@ mod tests { let instructions = DeveloperInstructions::from_policy( &policy, AskForApproval::UnlessTrusted, + ApprovalsReviewer::User, &Policy::empty(), &PathBuf::from("/tmp"), false, @@ -2016,11 +2041,14 @@ mod tests { let instructions = DeveloperInstructions::from_permissions_with_network( SandboxMode::WorkspaceWrite, NetworkAccess::Enabled, - AskForApproval::OnRequest, - &exec_policy, + PermissionsPromptConfig { + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::User, + exec_policy: &exec_policy, + exec_permission_approvals_enabled: false, + request_permissions_tool_enabled: false, + }, None, - false, - false, ); let text = instructions.into_text(); @@ -2034,11 +2062,14 @@ mod tests { let instructions = DeveloperInstructions::from_permissions_with_network( SandboxMode::WorkspaceWrite, NetworkAccess::Enabled, - AskForApproval::UnlessTrusted, - &Policy::empty(), + PermissionsPromptConfig { + approval_policy: AskForApproval::UnlessTrusted, + approvals_reviewer: ApprovalsReviewer::User, + exec_policy: &Policy::empty(), + exec_permission_approvals_enabled: false, + request_permissions_tool_enabled: true, + }, None, - false, - true, ); let text = instructions.into_text(); @@ -2051,11 +2082,14 @@ mod tests { let instructions = DeveloperInstructions::from_permissions_with_network( SandboxMode::WorkspaceWrite, NetworkAccess::Enabled, - AskForApproval::OnFailure, - &Policy::empty(), + PermissionsPromptConfig { + approval_policy: AskForApproval::OnFailure, + approvals_reviewer: ApprovalsReviewer::User, + exec_policy: &Policy::empty(), + exec_permission_approvals_enabled: false, + request_permissions_tool_enabled: true, + }, None, - false, - true, ); let text = instructions.into_text(); @@ -2068,11 +2102,14 @@ mod tests { let instructions = DeveloperInstructions::from_permissions_with_network( SandboxMode::WorkspaceWrite, NetworkAccess::Enabled, - AskForApproval::OnRequest, - &Policy::empty(), + PermissionsPromptConfig { + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::User, + exec_policy: &Policy::empty(), + exec_permission_approvals_enabled: true, + request_permissions_tool_enabled: false, + }, None, - true, - false, ); let text = instructions.into_text(); @@ -2085,11 +2122,14 @@ mod tests { let instructions = DeveloperInstructions::from_permissions_with_network( SandboxMode::WorkspaceWrite, NetworkAccess::Enabled, - AskForApproval::OnRequest, - &Policy::empty(), + PermissionsPromptConfig { + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::User, + exec_policy: &Policy::empty(), + exec_permission_approvals_enabled: false, + request_permissions_tool_enabled: true, + }, None, - false, - true, ); let text = instructions.into_text(); @@ -2104,11 +2144,14 @@ mod tests { let instructions = DeveloperInstructions::from_permissions_with_network( SandboxMode::WorkspaceWrite, NetworkAccess::Enabled, - AskForApproval::OnRequest, - &Policy::empty(), + PermissionsPromptConfig { + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::User, + exec_policy: &Policy::empty(), + exec_permission_approvals_enabled: true, + request_permissions_tool_enabled: true, + }, None, - true, - true, ); let text = instructions.into_text(); @@ -2116,6 +2159,35 @@ mod tests { assert!(text.contains("# request_permissions Tool")); } + #[test] + fn guardian_subagent_approvals_append_guardian_specific_guidance() { + let text = DeveloperInstructions::from( + AskForApproval::OnRequest, + ApprovalsReviewer::GuardianSubagent, + &Policy::empty(), + false, + false, + ) + .into_text(); + + assert!(text.contains("`approvals_reviewer` is `guardian_subagent`")); + assert!(text.contains("materially safer alternative")); + } + + #[test] + fn guardian_subagent_approvals_omit_guardian_specific_guidance_when_approval_is_never() { + let text = DeveloperInstructions::from( + AskForApproval::Never, + ApprovalsReviewer::GuardianSubagent, + &Policy::empty(), + false, + false, + ) + .into_text(); + + assert!(!text.contains("`approvals_reviewer` is `guardian_subagent`")); + } + fn granular_categories_section(title: &str, categories: &[&str]) -> String { format!("{title}\n{}", categories.join("\n")) } @@ -2158,6 +2230,7 @@ mod tests { request_permissions: true, mcp_elicitations: false, }), + ApprovalsReviewer::User, &Policy::empty(), true, false, @@ -2191,6 +2264,7 @@ mod tests { request_permissions: true, mcp_elicitations: true, }), + ApprovalsReviewer::User, &Policy::empty(), true, false, @@ -2223,6 +2297,7 @@ mod tests { request_permissions: true, mcp_elicitations: true, }), + ApprovalsReviewer::User, &Policy::empty(), false, false, @@ -2255,6 +2330,7 @@ mod tests { request_permissions: true, mcp_elicitations: true, }), + ApprovalsReviewer::User, &Policy::empty(), true, true, @@ -2270,6 +2346,7 @@ mod tests { request_permissions: false, mcp_elicitations: true, }), + ApprovalsReviewer::User, &Policy::empty(), true, true, @@ -2289,6 +2366,7 @@ mod tests { request_permissions: true, mcp_elicitations: false, }), + ApprovalsReviewer::User, &Policy::empty(), true, false, diff --git a/codex-rs/protocol/src/permissions.rs b/codex-rs/protocol/src/permissions.rs index 590fbd1fe1..0a5b337029 100644 --- a/codex-rs/protocol/src/permissions.rs +++ b/codex-rs/protocol/src/permissions.rs @@ -347,6 +347,48 @@ impl FileSystemSandboxPolicy { self.resolve_access_with_cwd(path, cwd).can_write() } + pub fn with_additional_readable_roots( + mut self, + cwd: &Path, + additional_readable_roots: &[AbsolutePathBuf], + ) -> Self { + if self.has_full_disk_read_access() { + return self; + } + + for path in additional_readable_roots { + if self.can_read_path_with_cwd(path.as_path(), cwd) { + continue; + } + + self.entries.push(FileSystemSandboxEntry { + path: FileSystemPath::Path { path: path.clone() }, + access: FileSystemAccessMode::Read, + }); + } + + self + } + + pub fn with_additional_writable_roots( + mut self, + cwd: &Path, + additional_writable_roots: &[AbsolutePathBuf], + ) -> Self { + for path in additional_writable_roots { + if self.can_write_path_with_cwd(path.as_path(), cwd) { + continue; + } + + self.entries.push(FileSystemSandboxEntry { + path: FileSystemPath::Path { path: path.clone() }, + access: FileSystemAccessMode::Write, + }); + } + + self + } + pub fn needs_direct_runtime_enforcement( &self, network_policy: NetworkSandboxPolicy, @@ -1782,6 +1824,74 @@ mod tests { ); } + #[test] + fn with_additional_readable_roots_skips_existing_effective_access() { + let cwd = TempDir::new().expect("tempdir"); + let cwd_root = AbsolutePathBuf::from_absolute_path(cwd.path()).expect("absolute cwd"); + let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: FileSystemAccessMode::Read, + }]); + + let actual = policy + .clone() + .with_additional_readable_roots(cwd.path(), std::slice::from_ref(&cwd_root)); + + assert_eq!(actual, policy); + } + + #[test] + fn with_additional_writable_roots_skips_existing_effective_access() { + let cwd = TempDir::new().expect("tempdir"); + let cwd_root = AbsolutePathBuf::from_absolute_path(cwd.path()).expect("absolute cwd"); + let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: FileSystemAccessMode::Write, + }]); + + let actual = policy + .clone() + .with_additional_writable_roots(cwd.path(), std::slice::from_ref(&cwd_root)); + + assert_eq!(actual, policy); + } + + #[test] + fn with_additional_writable_roots_adds_new_root() { + let temp_dir = TempDir::new().expect("tempdir"); + let cwd = temp_dir.path().join("workspace"); + let extra = AbsolutePathBuf::from_absolute_path(temp_dir.path().join("extra")) + .expect("resolve extra root"); + let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: FileSystemAccessMode::Write, + }]); + + let actual = policy.with_additional_writable_roots(&cwd, std::slice::from_ref(&extra)); + + assert_eq!( + actual, + FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: extra }, + access: FileSystemAccessMode::Write, + }, + ]) + ); + } + #[test] fn file_system_access_mode_orders_by_conflict_precedence() { assert!(FileSystemAccessMode::Write > FileSystemAccessMode::Read); diff --git a/codex-rs/protocol/src/prompts/permissions/approval_policy/on_request_rule.md b/codex-rs/protocol/src/prompts/permissions/approval_policy/on_request.md similarity index 90% rename from codex-rs/protocol/src/prompts/permissions/approval_policy/on_request_rule.md rename to codex-rs/protocol/src/prompts/permissions/approval_policy/on_request.md index 3928a91b7e..092f375c13 100644 --- a/codex-rs/protocol/src/prompts/permissions/approval_policy/on_request_rule.md +++ b/codex-rs/protocol/src/prompts/permissions/approval_policy/on_request.md @@ -19,6 +19,8 @@ This is treated as two command segments: ["tee", "output.txt"] +Commands that use more advanced shell features like redirection (>, >>, <), substitutions ($(...), ...), environment variables (FOO=bar), or wildcard patterns (*, ?) will not be evaluated against rules, to limit the scope of what an approved rule allows. + ## How to request escalation IMPORTANT: To request approval to execute a command that will require escalated privileges: @@ -44,7 +46,7 @@ While commands are running inside the sandbox, here are some scenarios that will When choosing a `prefix_rule`, request one that will allow you to fulfill similar requests from the user in the future without re-requesting escalation. It should be categorical and reasonably scoped to similar capabilities. You should rarely pass the entire command into `prefix_rule`. ### Banned prefix_rules -Avoid requesting overly broad prefixes that the user would be ill-advised to approve. For example, do not request ["python3"], ["python", "-"], or other similar prefixes. +Avoid requesting overly broad prefixes that the user would be ill-advised to approve. For example, do not request ["python3"], ["python", "-"], or other similar prefixes that would allow arbitrary scripting. NEVER provide a prefix_rule argument for destructive commands like rm. NEVER provide a prefix_rule if your command uses a heredoc or herestring. @@ -52,5 +54,4 @@ NEVER provide a prefix_rule if your command uses a heredoc or herestring. Good examples of prefixes: - ["npm", "run", "dev"] - ["gh", "pr", "check"] -- ["pytest"] - ["cargo", "test"] diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 88ba219f70..7fa9996b73 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -12,6 +12,7 @@ use std::path::PathBuf; use std::str::FromStr; use std::time::Duration; +use crate::AgentPath; use crate::ThreadId; use crate::approvals::ElicitationRequestEvent; use crate::config_types::ApprovalsReviewer; @@ -1537,6 +1538,15 @@ pub enum AgentStatus { NotFound, } +/// Turn kinds that reject same-turn steering. +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum NonSteerableTurnKind { + Review, + Compact, +} + /// Codex errors that we expose to clients. #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] @@ -1564,6 +1574,11 @@ pub enum CodexErrorInfo { ResponseTooManyFailedAttempts { http_status_code: Option, }, + /// Returned when `turn/start` or `turn/steer` is submitted while the current active turn + /// cannot accept same-turn steering, for example `/review` or manual `/compact`. + ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind, + }, ThreadRollbackFailed, Other, } @@ -1572,7 +1587,7 @@ impl CodexErrorInfo { /// Whether this error should mark the current turn as failed when replaying history. pub fn affects_turn_status(&self) -> bool { match self { - Self::ThreadRollbackFailed => false, + Self::ThreadRollbackFailed | Self::ActiveTurnNotSteerable { .. } => false, Self::ContextWindowExceeded | Self::UsageLimitExceeded | Self::ServerOverloaded @@ -2288,6 +2303,8 @@ pub enum SubAgentSource { parent_thread_id: ThreadId, depth: i32, #[serde(default)] + agent_path: Option, + #[serde(default)] agent_nickname: Option, #[serde(default, alias = "agent_type")] agent_role: Option, @@ -2351,6 +2368,16 @@ impl SessionSource { _ => None, } } + + pub fn get_agent_path(&self) -> Option { + match self { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_path, .. }) => { + agent_path.clone() + } + _ => None, + } + } + pub fn restriction_product(&self) -> Option { match self { SessionSource::Custom(source) => Product::from_session_source_name(source), @@ -2411,6 +2438,9 @@ pub struct SessionMeta { /// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. #[serde(default, alias = "agent_type", skip_serializing_if = "Option::is_none")] pub agent_role: Option, + /// Optional canonical agent path assigned to an AgentControl-spawned sub-agent. + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_path: Option, pub model_provider: Option, /// base_instructions for the session. This *should* always be present when creating a new session, /// but may be missing for older sessions. If not present, fall back to rendering the base_instructions @@ -2434,6 +2464,7 @@ impl Default for SessionMeta { source: SessionSource::default(), agent_nickname: None, agent_role: None, + agent_path: None, model_provider: None, base_instructions: None, dynamic_tools: None, @@ -2962,6 +2993,14 @@ pub enum Product { Atlas, } impl Product { + pub fn to_app_platform(self) -> &'static str { + match self { + Self::Chatgpt => "chat", + Self::Codex => "codex", + Self::Atlas => "atlas", + } + } + pub fn from_session_source_name(value: &str) -> Option { let normalized = value.trim().to_ascii_lowercase(); match normalized.as_str() { @@ -4187,6 +4226,17 @@ mod tests { assert!(!event.affects_turn_status()); } + #[test] + fn active_turn_not_steerable_error_does_not_affect_turn_status() { + let event = ErrorEvent { + message: "cannot steer a review turn".into(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + }; + assert!(!event.affects_turn_status()); + } + #[test] fn generic_error_affects_turn_status() { let event = ErrorEvent { diff --git a/codex-rs/state/migrations/0022_threads_agent_path.sql b/codex-rs/state/migrations/0022_threads_agent_path.sql new file mode 100644 index 0000000000..9340945703 --- /dev/null +++ b/codex-rs/state/migrations/0022_threads_agent_path.sql @@ -0,0 +1 @@ +ALTER TABLE threads ADD COLUMN agent_path TEXT; diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index 2660bb4964..05a2a04e39 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -50,6 +50,7 @@ fn apply_session_meta_from_item(metadata: &mut ThreadMetadata, meta_line: &Sessi metadata.source = enum_to_string(&meta_line.meta.source); metadata.agent_nickname = meta_line.meta.agent_nickname.clone(); metadata.agent_role = meta_line.meta.agent_role.clone(); + metadata.agent_path = meta_line.meta.agent_path.clone(); if let Some(provider) = meta_line.meta.model_provider.as_deref() { metadata.model_provider = provider.to_string(); } @@ -252,6 +253,7 @@ mod tests { originator: "codex_cli_rs".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::Cli, + agent_path: None, agent_nickname: None, agent_role: None, model_provider: Some("openai".to_string()), @@ -378,6 +380,7 @@ mod tests { originator: "codex_cli_rs".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::Cli, + agent_path: None, agent_nickname: None, agent_role: None, model_provider: Some("openai".to_string()), @@ -403,6 +406,7 @@ mod tests { created_at, updated_at: created_at, source: "cli".to_string(), + agent_path: None, agent_nickname: None, agent_role: None, model_provider: "openai".to_string(), diff --git a/codex-rs/state/src/model/thread_metadata.rs b/codex-rs/state/src/model/thread_metadata.rs index db4a2d95e7..03a8a6f94e 100644 --- a/codex-rs/state/src/model/thread_metadata.rs +++ b/codex-rs/state/src/model/thread_metadata.rs @@ -69,6 +69,8 @@ pub struct ThreadMetadata { pub agent_nickname: Option, /// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. pub agent_role: Option, + /// Optional canonical agent path assigned to an AgentControl-spawned sub-agent. + pub agent_path: Option, /// The model provider identifier. pub model_provider: String, /// The latest observed model for the thread. @@ -116,6 +118,8 @@ pub struct ThreadMetadataBuilder { pub agent_nickname: Option, /// Optional role (agent_role) assigned to the session. pub agent_role: Option, + /// Optional canonical agent path assigned to the session. + pub agent_path: Option, /// The model provider identifier, if known. pub model_provider: Option, /// The working directory for the thread. @@ -152,6 +156,7 @@ impl ThreadMetadataBuilder { source, agent_nickname: None, agent_role: None, + agent_path: None, model_provider: None, cwd: PathBuf::new(), cli_version: None, @@ -182,6 +187,10 @@ impl ThreadMetadataBuilder { source, agent_nickname: self.agent_nickname.clone(), agent_role: self.agent_role.clone(), + agent_path: self + .agent_path + .clone() + .or_else(|| self.source.get_agent_path().map(Into::into)), model_provider: self .model_provider .clone() @@ -241,6 +250,9 @@ impl ThreadMetadata { if self.agent_role != other.agent_role { diffs.push("agent_role"); } + if self.agent_path != other.agent_path { + diffs.push("agent_path"); + } if self.model_provider != other.model_provider { diffs.push("model_provider"); } @@ -300,6 +312,7 @@ pub(crate) struct ThreadRow { source: String, agent_nickname: Option, agent_role: Option, + agent_path: Option, model_provider: String, model: Option, reasoning_effort: Option, @@ -326,6 +339,7 @@ impl ThreadRow { source: row.try_get("source")?, agent_nickname: row.try_get("agent_nickname")?, agent_role: row.try_get("agent_role")?, + agent_path: row.try_get("agent_path")?, model_provider: row.try_get("model_provider")?, model: row.try_get("model")?, reasoning_effort: row.try_get("reasoning_effort")?, @@ -356,6 +370,7 @@ impl TryFrom for ThreadMetadata { source, agent_nickname, agent_role, + agent_path, model_provider, model, reasoning_effort, @@ -379,6 +394,7 @@ impl TryFrom for ThreadMetadata { source, agent_nickname, agent_role, + agent_path, model_provider, model, reasoning_effort: reasoning_effort @@ -447,6 +463,7 @@ mod tests { source: "cli".to_string(), agent_nickname: None, agent_role: None, + agent_path: None, model_provider: "openai".to_string(), model: Some("gpt-5".to_string()), reasoning_effort: reasoning_effort.map(str::to_string), @@ -474,6 +491,7 @@ mod tests { source: "cli".to_string(), agent_nickname: None, agent_role: None, + agent_path: None, model_provider: "openai".to_string(), model: Some("gpt-5".to_string()), reasoning_effort, diff --git a/codex-rs/state/src/runtime/memories.rs b/codex-rs/state/src/runtime/memories.rs index 5ca33885f3..386e0fd3e3 100644 --- a/codex-rs/state/src/runtime/memories.rs +++ b/codex-rs/state/src/runtime/memories.rs @@ -166,6 +166,7 @@ SELECT created_at, updated_at, source, + agent_path, agent_nickname, agent_role, model_provider, diff --git a/codex-rs/state/src/runtime/test_support.rs b/codex-rs/state/src/runtime/test_support.rs index 229ece64b4..5f07336853 100644 --- a/codex-rs/state/src/runtime/test_support.rs +++ b/codex-rs/state/src/runtime/test_support.rs @@ -50,6 +50,7 @@ pub(super) fn test_thread_metadata( source: "cli".to_string(), agent_nickname: None, agent_role: None, + agent_path: None, model_provider: "test-provider".to_string(), model: Some("gpt-5".to_string()), reasoning_effort: Some(ReasoningEffort::Medium), diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index 1f62deb622..0972f9d1fd 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -13,6 +13,7 @@ SELECT source, agent_nickname, agent_role, + agent_path, model_provider, model, reasoning_effort, @@ -142,6 +143,62 @@ ON CONFLICT(child_thread_id) DO UPDATE SET .await } + /// Find a direct spawned child of `parent_thread_id` by canonical agent path. + pub async fn find_thread_spawn_child_by_path( + &self, + parent_thread_id: ThreadId, + agent_path: &str, + ) -> anyhow::Result> { + let rows = sqlx::query( + r#" +SELECT threads.id +FROM thread_spawn_edges +JOIN threads ON threads.id = thread_spawn_edges.child_thread_id +WHERE thread_spawn_edges.parent_thread_id = ? + AND threads.agent_path = ? +ORDER BY threads.id +LIMIT 2 + "#, + ) + .bind(parent_thread_id.to_string()) + .bind(agent_path) + .fetch_all(self.pool.as_ref()) + .await?; + one_thread_id_from_rows(rows, agent_path) + } + + /// Find a spawned descendant of `root_thread_id` by canonical agent path. + pub async fn find_thread_spawn_descendant_by_path( + &self, + root_thread_id: ThreadId, + agent_path: &str, + ) -> anyhow::Result> { + let rows = sqlx::query( + r#" +WITH RECURSIVE subtree(child_thread_id) AS ( + SELECT child_thread_id + FROM thread_spawn_edges + WHERE parent_thread_id = ? + UNION ALL + SELECT edge.child_thread_id + FROM thread_spawn_edges AS edge + JOIN subtree ON edge.parent_thread_id = subtree.child_thread_id +) +SELECT threads.id +FROM subtree +JOIN threads ON threads.id = subtree.child_thread_id +WHERE threads.agent_path = ? +ORDER BY threads.id +LIMIT 2 + "#, + ) + .bind(root_thread_id.to_string()) + .bind(agent_path) + .fetch_all(self.pool.as_ref()) + .await?; + one_thread_id_from_rows(rows, agent_path) + } + async fn list_thread_spawn_children_matching( &self, parent_thread_id: ThreadId, @@ -293,6 +350,7 @@ SELECT source, agent_nickname, agent_role, + agent_path, model_provider, model, reasoning_effort, @@ -393,6 +451,7 @@ INSERT INTO threads ( source, agent_nickname, agent_role, + agent_path, model_provider, model, reasoning_effort, @@ -409,7 +468,7 @@ INSERT INTO threads ( git_branch, git_origin_url, memory_mode -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO NOTHING "#, ) @@ -420,6 +479,7 @@ ON CONFLICT(id) DO NOTHING .bind(metadata.source.as_str()) .bind(metadata.agent_nickname.as_deref()) .bind(metadata.agent_role.as_deref()) + .bind(metadata.agent_path.as_deref()) .bind(metadata.model_provider.as_str()) .bind(metadata.model.as_deref()) .bind( @@ -518,6 +578,7 @@ INSERT INTO threads ( source, agent_nickname, agent_role, + agent_path, model_provider, model, reasoning_effort, @@ -534,7 +595,7 @@ INSERT INTO threads ( git_branch, git_origin_url, memory_mode -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET rollout_path = excluded.rollout_path, created_at = excluded.created_at, @@ -542,6 +603,7 @@ ON CONFLICT(id) DO UPDATE SET source = excluded.source, agent_nickname = excluded.agent_nickname, agent_role = excluded.agent_role, + agent_path = excluded.agent_path, model_provider = excluded.model_provider, model = excluded.model, reasoning_effort = excluded.reasoning_effort, @@ -566,6 +628,7 @@ ON CONFLICT(id) DO UPDATE SET .bind(metadata.source.as_str()) .bind(metadata.agent_nickname.as_deref()) .bind(metadata.agent_role.as_deref()) + .bind(metadata.agent_path.as_deref()) .bind(metadata.model_provider.as_str()) .bind(metadata.model.as_deref()) .bind( @@ -753,6 +816,26 @@ ON CONFLICT(thread_id, position) DO NOTHING } } +fn one_thread_id_from_rows( + rows: Vec, + agent_path: &str, +) -> anyhow::Result> { + let mut ids = rows + .into_iter() + .map(|row| { + let id: String = row.try_get("id")?; + ThreadId::try_from(id).map_err(anyhow::Error::from) + }) + .collect::, _>>()?; + match ids.len() { + 0 => Ok(None), + 1 => Ok(ids.pop()), + _ => Err(anyhow::anyhow!( + "multiple agents found for canonical path `{agent_path}`" + )), + } +} + pub(super) fn extract_dynamic_tools(items: &[RolloutItem]) -> Option>> { items.iter().find_map(|item| match item { RolloutItem::SessionMeta(meta_line) => Some(meta_line.meta.dynamic_tools.clone()), @@ -942,6 +1025,7 @@ mod tests { originator: String::new(), cli_version: String::new(), source: SessionSource::Cli, + agent_path: None, agent_nickname: None, agent_role: None, model_provider: None, @@ -996,6 +1080,7 @@ mod tests { originator: String::new(), cli_version: String::new(), source: SessionSource::Cli, + agent_path: None, agent_nickname: None, agent_role: None, model_provider: None, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4aa51df21c..4086efd985 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -398,8 +398,8 @@ fn emit_project_config_warnings(app_event_tx: &AppEventSender, config: &Config) ))); } -fn emit_missing_system_bwrap_warning(app_event_tx: &AppEventSender) { - let Some(message) = codex_core::config::missing_system_bwrap_warning() else { +fn emit_system_bwrap_warning(app_event_tx: &AppEventSender) { + let Some(message) = codex_core::config::system_bwrap_warning() else { return; }; @@ -2197,7 +2197,7 @@ impl App { let (app_event_tx, mut app_event_rx) = unbounded_channel(); let app_event_tx = AppEventSender::new(app_event_tx); emit_project_config_warnings(&app_event_tx, &config); - emit_missing_system_bwrap_warning(&app_event_tx); + emit_system_bwrap_warning(&app_event_tx); emit_custom_prompt_deprecation_notice(&app_event_tx, &config.codex_home).await; tui.set_notification_method(config.tui_notification_method); @@ -5008,7 +5008,7 @@ mod tests { app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() @@ -5090,7 +5090,7 @@ mod tests { app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() @@ -5171,7 +5171,7 @@ mod tests { app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() @@ -5246,7 +5246,7 @@ mod tests { app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index ee0a7bb636..90106f31fd 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -403,6 +403,7 @@ pub(crate) struct ChatComposer { config: ChatComposerConfig, collaboration_mode_indicator: Option, connectors_enabled: bool, + plugins_command_enabled: bool, fast_command_enabled: bool, personality_command_enabled: bool, realtime_conversation_enabled: bool, @@ -441,6 +442,7 @@ impl ChatComposer { BuiltinCommandFlags { collaboration_modes_enabled: self.collaboration_modes_enabled, connectors_enabled: self.connectors_enabled, + plugins_command_enabled: self.plugins_command_enabled, fast_command_enabled: self.fast_command_enabled, personality_command_enabled: self.personality_command_enabled, realtime_conversation_enabled: self.realtime_conversation_enabled, @@ -525,6 +527,7 @@ impl ChatComposer { config, collaboration_mode_indicator: None, connectors_enabled: false, + plugins_command_enabled: false, fast_command_enabled: false, personality_command_enabled: false, realtime_conversation_enabled: false, @@ -559,6 +562,10 @@ impl ChatComposer { self.sync_popups(); } + pub fn set_plugins_command_enabled(&mut self, enabled: bool) { + self.plugins_command_enabled = enabled; + } + /// Toggle composer-side image paste handling. /// /// This only affects whether image-like paste content is converted into attachments; the @@ -3477,6 +3484,7 @@ impl ChatComposer { if is_editing_slash_command_name { let collaboration_modes_enabled = self.collaboration_modes_enabled; let connectors_enabled = self.connectors_enabled; + let plugins_command_enabled = self.plugins_command_enabled; let fast_command_enabled = self.fast_command_enabled; let personality_command_enabled = self.personality_command_enabled; let realtime_conversation_enabled = self.realtime_conversation_enabled; @@ -3486,6 +3494,7 @@ impl ChatComposer { CommandPopupFlags { collaboration_modes_enabled, connectors_enabled, + plugins_command_enabled, fast_command_enabled, personality_command_enabled, realtime_conversation_enabled, @@ -3575,6 +3584,13 @@ impl ChatComposer { fn mention_items(&self) -> Vec { let mut mentions = Vec::new(); + let plugin_display_names: HashSet = + self.plugins.as_ref().map_or_else(HashSet::new, |plugins| { + plugins + .iter() + .map(|plugin| plugin.display_name.to_ascii_lowercase()) + .collect() + }); if let Some(skills) = self.skills.as_ref() { for skill in skills { @@ -3657,18 +3673,30 @@ impl ChatComposer { if !connector.is_accessible || !connector.is_enabled { continue; } + let plugin_backed_connector = connector + .plugin_display_names + .iter() + .any(|name| plugin_display_names.contains(&name.to_ascii_lowercase())); + if plugin_backed_connector { + continue; + } let display_name = connectors::connector_display_label(connector); let description = Some(Self::connector_brief_description(connector)); let slug = codex_core::connectors::connector_mention_slug(connector); let search_terms = vec![display_name.clone(), connector.id.clone(), slug.clone()]; let connector_id = connector.id.as_str(); + let category_tag = if connector.plugin_display_names.is_empty() { + "[App]".to_string() + } else { + "[Plugin]".to_string() + }; mentions.push(MentionItem { display_name: display_name.clone(), description, insert_text: format!("${slug}"), search_terms, path: Some(format!("app://{connector_id}")), - category_tag: Some("[App]".to_string()), + category_tag: Some(category_tag), sort_rank: 1, }); } @@ -5374,6 +5402,93 @@ mod tests { assert_eq!(mention.path, Some("plugin://sample@test".to_string())); } + #[test] + fn mention_items_keep_plugin_owned_skills_but_hide_duplicate_apps() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_connectors_enabled(true); + composer.set_text_content("$goog".to_string(), Vec::new(), Vec::new()); + composer.set_skill_mentions(Some(vec![SkillMetadata { + name: "google-calendar:availability".to_string(), + description: "Find availability and plan event changes".to_string(), + short_description: None, + interface: Some(codex_core::skills::model::SkillInterface { + display_name: Some("Google Calendar".to_string()), + short_description: None, + icon_small: None, + icon_large: None, + brand_color: None, + default_prompt: None, + }), + dependencies: None, + policy: None, + permission_profile: None, + managed_network_override: None, + path_to_skills_md: PathBuf::from("/tmp/repo/google-calendar/SKILL.md"), + scope: codex_protocol::protocol::SkillScope::Repo, + }])); + composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary { + config_name: "google-calendar@debug".to_string(), + display_name: "Google Calendar".to_string(), + description: Some( + "Connect Google Calendar for scheduling, availability, and event management." + .to_string(), + ), + has_skills: true, + mcp_server_names: vec!["google-calendar".to_string()], + app_connector_ids: vec![codex_core::plugins::AppConnectorId( + "google_calendar".to_string(), + )], + }])); + composer.set_connector_mentions(Some(ConnectorsSnapshot { + connectors: vec![AppInfo { + id: "google_calendar".to_string(), + name: "Google Calendar".to_string(), + description: Some("Look up events and availability".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://example.test/google-calendar".to_string()), + is_accessible: true, + is_enabled: true, + plugin_display_names: vec!["Google Calendar".to_string()], + }], + })); + + let mut mention_summaries: Vec<_> = composer + .mention_items() + .into_iter() + .map(|mention| (mention.display_name, mention.category_tag, mention.path)) + .collect(); + mention_summaries.sort(); + + assert_eq!( + mention_summaries, + vec![ + ( + "Google Calendar".to_string(), + Some("[Plugin]".to_string()), + Some("plugin://google-calendar@debug".to_string()), + ), + ( + "Google Calendar".to_string(), + Some("[Skill]".to_string()), + Some("/tmp/repo/google-calendar/SKILL.md".to_string()), + ), + ] + ); + } + #[test] fn plugin_mention_popup_snapshot() { snapshot_composer_state("plugin_mention_popup", false, |composer| { diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 8508849689..e7269c38ef 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -38,6 +38,7 @@ pub(crate) struct CommandPopup { pub(crate) struct CommandPopupFlags { pub(crate) collaboration_modes_enabled: bool, pub(crate) connectors_enabled: bool, + pub(crate) plugins_command_enabled: bool, pub(crate) fast_command_enabled: bool, pub(crate) personality_command_enabled: bool, pub(crate) realtime_conversation_enabled: bool, @@ -50,6 +51,7 @@ impl From for slash_commands::BuiltinCommandFlags { Self { collaboration_modes_enabled: value.collaboration_modes_enabled, connectors_enabled: value.connectors_enabled, + plugins_command_enabled: value.plugins_command_enabled, fast_command_enabled: value.fast_command_enabled, personality_command_enabled: value.personality_command_enabled, realtime_conversation_enabled: value.realtime_conversation_enabled, @@ -509,6 +511,7 @@ mod tests { CommandPopupFlags { collaboration_modes_enabled: true, connectors_enabled: false, + plugins_command_enabled: false, fast_command_enabled: false, personality_command_enabled: true, realtime_conversation_enabled: false, @@ -531,6 +534,7 @@ mod tests { CommandPopupFlags { collaboration_modes_enabled: true, connectors_enabled: false, + plugins_command_enabled: false, fast_command_enabled: false, personality_command_enabled: true, realtime_conversation_enabled: false, @@ -553,6 +557,7 @@ mod tests { CommandPopupFlags { collaboration_modes_enabled: true, connectors_enabled: false, + plugins_command_enabled: false, fast_command_enabled: false, personality_command_enabled: false, realtime_conversation_enabled: false, @@ -583,6 +588,7 @@ mod tests { CommandPopupFlags { collaboration_modes_enabled: true, connectors_enabled: false, + plugins_command_enabled: false, fast_command_enabled: false, personality_command_enabled: true, realtime_conversation_enabled: false, @@ -605,6 +611,7 @@ mod tests { CommandPopupFlags { collaboration_modes_enabled: false, connectors_enabled: false, + plugins_command_enabled: false, fast_command_enabled: false, personality_command_enabled: true, realtime_conversation_enabled: true, diff --git a/codex-rs/tui/src/bottom_pane/file_search_popup.rs b/codex-rs/tui/src/bottom_pane/file_search_popup.rs index c1c5296648..fe903a0b37 100644 --- a/codex-rs/tui/src/bottom_pane/file_search_popup.rs +++ b/codex-rs/tui/src/bottom_pane/file_search_popup.rs @@ -70,7 +70,7 @@ impl FileSearchPopup { } self.display_query = query.to_string(); - self.matches = matches; + self.matches = matches.into_iter().take(MAX_POPUP_ROWS).collect(); self.waiting = false; let len = self.matches.len(); self.state.clamp_selection(len); @@ -152,3 +152,33 @@ impl WidgetRef for &FileSearchPopup { ); } } + +#[cfg(test)] +mod tests { + use super::*; + use codex_file_search::MatchType; + use pretty_assertions::assert_eq; + + fn file_match(index: usize) -> FileMatch { + FileMatch { + score: index as u32, + path: PathBuf::from(format!("src/file_{index:02}.rs")), + match_type: MatchType::File, + root: PathBuf::from("/tmp/repo"), + indices: None, + } + } + + #[test] + fn set_matches_keeps_only_the_first_page_of_results() { + let mut popup = FileSearchPopup::new(); + popup.set_query("file"); + popup.set_matches("file", (0..(MAX_POPUP_ROWS + 2)).map(file_match).collect()); + + assert_eq!( + popup.matches, + (0..MAX_POPUP_ROWS).map(file_match).collect::>() + ); + assert_eq!(popup.calculate_required_height(), MAX_POPUP_ROWS as u16); + } +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 56b25dfa1a..eda37fe9c4 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -266,6 +266,11 @@ impl BottomPane { self.request_redraw(); } + pub fn set_plugins_command_enabled(&mut self, enabled: bool) { + self.composer.set_plugins_command_enabled(enabled); + self.request_redraw(); + } + pub fn take_mention_bindings(&mut self) -> Vec { self.composer.take_mention_bindings() } @@ -820,8 +825,10 @@ impl BottomPane { &mut self, queued: Vec, pending_steers: Vec, + rejected_steers: Vec, ) { self.pending_input_preview.pending_steers = pending_steers; + self.pending_input_preview.rejected_steers = rejected_steers; self.pending_input_preview.queued_messages = queued; self.request_redraw(); } @@ -1148,7 +1155,8 @@ impl BottomPane { } let has_pending_thread_approvals = !self.pending_thread_approvals.is_empty(); let has_pending_input = !self.pending_input_preview.queued_messages.is_empty() - || !self.pending_input_preview.pending_steers.is_empty(); + || !self.pending_input_preview.pending_steers.is_empty() + || !self.pending_input_preview.rejected_steers.is_empty(); let has_status_or_footer = self.status.is_some() || !self.unified_exec_footer.is_empty(); let has_inline_previews = has_pending_thread_approvals || has_pending_input; @@ -1551,7 +1559,11 @@ mod tests { StatusDetailsCapitalization::CapitalizeFirst, STATUS_DETAILS_DEFAULT_MAX_LINES, ); - pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new()); + pane.set_pending_input_preview( + vec!["Queued follow-up question".to_string()], + Vec::new(), + Vec::new(), + ); let width = 48; let height = pane.desired_height(width); @@ -1578,7 +1590,11 @@ mod tests { }); pane.set_task_running(true); - pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new()); + pane.set_pending_input_preview( + vec!["Queued follow-up question".to_string()], + Vec::new(), + Vec::new(), + ); pane.hide_status_indicator(); let width = 48; @@ -1606,7 +1622,11 @@ mod tests { }); pane.set_task_running(true); - pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new()); + pane.set_pending_input_preview( + vec!["Queued follow-up question".to_string()], + Vec::new(), + Vec::new(), + ); let width = 48; let height = pane.desired_height(width); diff --git a/codex-rs/tui/src/bottom_pane/pending_input_preview.rs b/codex-rs/tui/src/bottom_pane/pending_input_preview.rs index 315e311e02..b9690c28ee 100644 --- a/codex-rs/tui/src/bottom_pane/pending_input_preview.rs +++ b/codex-rs/tui/src/bottom_pane/pending_input_preview.rs @@ -10,17 +10,19 @@ use crate::render::renderable::Renderable; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_lines; -/// Widget that displays pending steers plus user messages queued while a turn is in progress. +/// Widget that displays pending steers plus follow-up messages held while a turn is in progress. /// -/// The widget renders pending steers first, then queued user messages, as two -/// labeled sections. Pending steers explain that they will be submitted after -/// the next tool/result boundary unless the user presses Esc to interrupt and -/// send them immediately. The edit hint at the bottom only appears when there -/// are actual queued user messages to pop back into the composer. Because some -/// terminals intercept certain modifier-key combinations, the displayed -/// binding is configurable via [`set_edit_binding`](Self::set_edit_binding). +/// The widget renders pending steers first, then rejected steers that will be +/// resubmitted at end of turn, then ordinary queued user messages. Pending +/// steers explain that they will be submitted after the next tool/result +/// boundary unless the user presses Esc to interrupt and send them +/// immediately. The edit hint at the bottom only appears when there are actual +/// queued user messages to pop back into the composer. Because some terminals +/// intercept certain modifier-key combinations, the displayed binding is +/// configurable via [`set_edit_binding`](Self::set_edit_binding). pub(crate) struct PendingInputPreview { pub pending_steers: Vec, + pub rejected_steers: Vec, pub queued_messages: Vec, /// Key combination rendered in the hint line. Defaults to Alt+Up but may /// be overridden for terminals where that chord is unavailable. @@ -33,6 +35,7 @@ impl PendingInputPreview { pub(crate) fn new() -> Self { Self { pending_steers: Vec::new(), + rejected_steers: Vec::new(), queued_messages: Vec::new(), edit_binding: key_hint::alt(KeyCode::Up), } @@ -67,7 +70,11 @@ impl PendingInputPreview { } fn as_renderable(&self, width: u16) -> Box { - if (self.pending_steers.is_empty() && self.queued_messages.is_empty()) || width < 4 { + if (self.pending_steers.is_empty() + && self.rejected_steers.is_empty() + && self.queued_messages.is_empty()) + || width < 4 + { return Box::new(()); } @@ -96,6 +103,27 @@ impl PendingInputPreview { } } + if !self.rejected_steers.is_empty() { + if !lines.is_empty() { + lines.push(Line::from("")); + } + Self::push_section_header( + &mut lines, + width, + "Messages to be submitted at end of turn".into(), + ); + + for steer in &self.rejected_steers { + let wrapped = adaptive_wrap_lines( + steer.lines().map(|line| Line::from(line.dim())), + RtOptions::new(width as usize) + .initial_indent(Line::from(" ↳ ".dim())) + .subsequent_indent(Line::from(" ")), + ); + Self::push_truncated_preview_lines(&mut lines, wrapped, Line::from(" …".dim())); + } + } + if !self.queued_messages.is_empty() { if !lines.is_empty() { lines.push(Line::from("")); @@ -176,6 +204,21 @@ mod tests { assert_snapshot!("render_one_message", format!("{buf:?}")); } + #[test] + fn render_one_message_with_shift_left_binding() { + let mut queue = PendingInputPreview::new(); + queue.queued_messages.push("Hello, world!".to_string()); + queue.set_edit_binding(key_hint::shift(KeyCode::Left)); + let width = 40; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!( + "render_one_message_with_shift_left_binding", + format!("{buf:?}") + ); + } + #[test] fn render_two_messages() { let mut queue = PendingInputPreview::new(); @@ -289,6 +332,9 @@ mod tests { queue .pending_steers .push("Check the last command output.".to_string()); + queue + .rejected_steers + .push("Rejected steer that will be retried.".to_string()); queue .queued_messages .push("Queued follow-up question".to_string()); diff --git a/codex-rs/tui/src/bottom_pane/skill_popup.rs b/codex-rs/tui/src/bottom_pane/skill_popup.rs index 841ce23b8b..061d3f3b82 100644 --- a/codex-rs/tui/src/bottom_pane/skill_popup.rs +++ b/codex-rs/tui/src/bottom_pane/skill_popup.rs @@ -180,6 +180,7 @@ impl SkillPopup { }) }); + out.truncate(MAX_POPUP_ROWS); out } } @@ -229,3 +230,43 @@ fn skill_popup_hint_line() -> Line<'static> { " to close".into(), ]) } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn mention_item(index: usize) -> MentionItem { + MentionItem { + display_name: format!("Mention {index:02}"), + description: Some(format!("Description {index:02}")), + insert_text: format!("$mention-{index:02}"), + search_terms: vec![format!("mention-{index:02}")], + path: Some(format!("skill://mention-{index:02}")), + category_tag: Some("[Skill]".to_string()), + sort_rank: 1, + } + } + + #[test] + fn filtered_mentions_are_capped_to_max_popup_rows() { + let popup = SkillPopup::new((0..(MAX_POPUP_ROWS + 2)).map(mention_item).collect()); + + let filtered_names: Vec = popup + .filtered_items() + .into_iter() + .map(|idx| popup.mentions[idx].display_name.clone()) + .collect(); + + assert_eq!( + filtered_names, + (0..MAX_POPUP_ROWS) + .map(|idx| format!("Mention {idx:02}")) + .collect::>() + ); + assert_eq!( + popup.calculate_required_height(72), + (MAX_POPUP_ROWS as u16) + 2 + ); + } +} diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index 15b70f232c..54b1a8cf4e 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -14,6 +14,7 @@ use crate::slash_command::built_in_slash_commands; pub(crate) struct BuiltinCommandFlags { pub(crate) collaboration_modes_enabled: bool, pub(crate) connectors_enabled: bool, + pub(crate) plugins_command_enabled: bool, pub(crate) fast_command_enabled: bool, pub(crate) personality_command_enabled: bool, pub(crate) realtime_conversation_enabled: bool, @@ -31,6 +32,7 @@ pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static st || !matches!(*cmd, SlashCommand::Collab | SlashCommand::Plan) }) .filter(|(_, cmd)| flags.connectors_enabled || *cmd != SlashCommand::Apps) + .filter(|(_, cmd)| flags.plugins_command_enabled || *cmd != SlashCommand::Plugins) .filter(|(_, cmd)| flags.fast_command_enabled || *cmd != SlashCommand::Fast) .filter(|(_, cmd)| flags.personality_command_enabled || *cmd != SlashCommand::Personality) .filter(|(_, cmd)| flags.realtime_conversation_enabled || *cmd != SlashCommand::Realtime) @@ -63,6 +65,7 @@ mod tests { BuiltinCommandFlags { collaboration_modes_enabled: true, connectors_enabled: true, + plugins_command_enabled: true, fast_command_enabled: true, personality_command_enabled: true, realtime_conversation_enabled: true, diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_message_with_shift_left_binding.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_message_with_shift_left_binding.snap new file mode 100644 index 0000000000..f3ad37e731 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_message_with_shift_left_binding.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/bottom_pane/pending_input_preview.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 3 }, + content: [ + "• Queued follow-up messages ", + " ↳ Hello, world! ", + " shift + ← edit last queued message ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 17, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 38, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap index 12744049fa..77d57c3f48 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap @@ -3,13 +3,16 @@ source: tui/src/bottom_pane/pending_input_preview.rs expression: "format!(\"{buf:?}\")" --- Buffer { - area: Rect { x: 0, y: 0, width: 52, height: 8 }, + area: Rect { x: 0, y: 0, width: 52, height: 11 }, content: [ "• Messages to be submitted after next tool call ", " (press esc to interrupt and send immediately) ", " ↳ Please continue. ", " ↳ Check the last command output. ", " ", + "• Messages to be submitted at end of turn ", + " ↳ Rejected steer that will be retried. ", + " ", "• Queued follow-up messages ", " ↳ Queued follow-up question ", " ⌥ + ↑ edit last queued message ", @@ -26,9 +29,13 @@ Buffer { x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 29, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 34, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 40, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 2, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 29, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 34, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, ] } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e2480c4ec0..7a42b5c268 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -155,6 +155,8 @@ use codex_protocol::request_permissions::RequestPermissionsEvent; use codex_protocol::request_user_input::RequestUserInputEvent; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; +use codex_terminal_detection::Multiplexer; +use codex_terminal_detection::TerminalInfo; use codex_terminal_detection::TerminalName; use codex_terminal_detection::terminal_info; use codex_utils_sleep_inhibitor::SleepInhibitor; @@ -195,14 +197,21 @@ const CONNECTORS_SELECTION_VIEW_ID: &str = "connectors-selection"; /// Choose the keybinding used to edit the most-recently queued message. /// /// Apple Terminal, Warp, and VSCode integrated terminals intercept or silently -/// swallow Alt+Up, so users in those environments would never be able to trigger -/// the edit action. We fall back to Shift+Left for those terminals while -/// keeping the more discoverable Alt+Up everywhere else. +/// swallow Alt+Up, and tmux does not reliably pass that chord through. We fall +/// back to Shift+Left for those environments while keeping the more discoverable +/// Alt+Up everywhere else. /// /// The match is exhaustive so that adding a new `TerminalName` variant forces /// an explicit decision about which binding that terminal should use. -fn queued_message_edit_binding_for_terminal(terminal_name: TerminalName) -> KeyBinding { - match terminal_name { +fn queued_message_edit_binding_for_terminal(terminal_info: TerminalInfo) -> KeyBinding { + if matches!( + terminal_info.multiplexer.as_ref(), + Some(Multiplexer::Tmux { .. }) + ) { + return key_hint::shift(KeyCode::Left); + } + + match terminal_info.name { TerminalName::AppleTerminal | TerminalName::WarpTerminal | TerminalName::VsCode => { key_hint::shift(KeyCode::Left) } @@ -720,6 +729,7 @@ pub(crate) struct ChatWidget { connectors_partial_snapshot: Option, connectors_prefetch_in_flight: bool, connectors_force_refetch_pending: bool, + pending_mcp_output_requests: usize, plugins_cache: PluginsCacheState, plugins_fetch_state: PluginListFetchState, // Queue of interruptive UI events deferred during an active write cycle @@ -755,6 +765,8 @@ pub(crate) struct ChatWidget { suppress_session_configured_redraw: bool, // User messages queued while a turn is in progress queued_user_messages: VecDeque, + // User messages that tried to steer a non-regular turn and must be retried first. + rejected_steers_queue: VecDeque, // Steers already submitted to core but not yet committed into history. // // The bottom pane shows these above queued drafts until core records the @@ -915,9 +927,11 @@ impl ThreadComposerState { pub(crate) struct ThreadInputState { composer: Option, pending_steers: VecDeque, + rejected_steers_queue: VecDeque, queued_user_messages: VecDeque, current_collaboration_mode: CollaborationMode, active_collaboration_mask: Option, + task_running: bool, agent_turn_running: bool, } @@ -1430,6 +1444,7 @@ impl ChatWidget { self.refresh_model_display(); self.sync_fast_command_enabled(); self.sync_personality_command_enabled(); + self.sync_plugins_command_enabled(); self.refresh_plugin_mentions(); let startup_tooltip_override = self.startup_tooltip_override.take(); let show_fast_status = self.should_show_fast_status(&model_for_header, event.service_tier); @@ -1455,9 +1470,6 @@ impl ChatWidget { cwds: Vec::new(), force_reload: true, }); - if self.connectors_enabled() { - self.prefetch_connectors(); - } if let Some(user_message) = self.initial_user_message.take() { self.submit_user_message(user_message); } @@ -1802,7 +1814,7 @@ impl ChatWidget { let had_pending_steers = !self.pending_steers.is_empty(); self.refresh_pending_input_preview(); - if !from_replay && self.queued_user_messages.is_empty() && !had_pending_steers { + if !from_replay && !self.has_queued_follow_up_messages() && !had_pending_steers { self.maybe_prompt_plan_implementation(); } // Keep this flag for replayed completion events so a subsequent live TurnComplete can @@ -1824,7 +1836,7 @@ impl ChatWidget { if !self.collaboration_modes_enabled() { return; } - if !self.queued_user_messages.is_empty() { + if self.has_queued_follow_up_messages() { return; } if self.active_mode_kind() != ModeKind::Plan { @@ -1896,6 +1908,50 @@ impl ChatWidget { }); } + fn has_queued_follow_up_messages(&self) -> bool { + !self.rejected_steers_queue.is_empty() || !self.queued_user_messages.is_empty() + } + + fn pop_next_queued_user_message(&mut self) -> Option { + if self.rejected_steers_queue.is_empty() { + self.queued_user_messages.pop_front() + } else { + Some(merge_user_messages( + self.rejected_steers_queue.drain(..).collect(), + )) + } + } + + fn pop_latest_queued_user_message(&mut self) -> Option { + self.queued_user_messages + .pop_back() + .or_else(|| self.rejected_steers_queue.pop_back()) + } + + pub(crate) fn enqueue_rejected_steer(&mut self) -> bool { + let Some(pending_steer) = self.pending_steers.pop_front() else { + tracing::warn!( + "received active-turn-not-steerable error without a matching pending steer" + ); + return false; + }; + self.rejected_steers_queue + .push_back(pending_steer.user_message); + if !self.bottom_pane.is_task_running() { + // Will drain rejected_steers_queue in case the steer rejection arrives after task completion + self.maybe_send_next_queued_input(); + } + self.refresh_pending_input_preview(); + true + } + + fn handle_steer_rejected_error(&mut self, codex_error_info: &CodexErrorInfo) -> bool { + matches!( + codex_error_info, + CodexErrorInfo::ActiveTurnNotSteerable { .. } + ) && self.enqueue_rejected_steer() + } + pub(crate) fn open_multi_agent_enable_prompt(&mut self) { let items = vec![ SelectionItem { @@ -2183,6 +2239,7 @@ impl ChatWidget { } fn on_mcp_startup_complete(&mut self, ev: McpStartupCompleteEvent) { + let codex_apps_ready = ev.ready.iter().any(|server| server == "codex_apps"); let mut parts = Vec::new(); if !ev.failed.is_empty() { let failed_servers: Vec<_> = ev.failed.iter().map(|f| f.server.clone()).collect(); @@ -2201,6 +2258,11 @@ impl ChatWidget { self.mcp_startup_status = None; self.update_task_running_state(); self.maybe_send_next_queued_input(); + if self.connectors_enabled() && codex_apps_ready { + // Populate `$` app mentions from the session's already-started MCP manager + // instead of doing a separate TUI-side connector prefetch. + self.submit_op(Op::ListMcpTools); + } self.request_redraw(); } @@ -2254,7 +2316,7 @@ impl ChatWidget { /// state stays aligned with the merged attachment list. Returns `None` when there is nothing to /// restore. fn drain_pending_messages_for_restore(&mut self) -> Option { - if self.pending_steers.is_empty() && self.queued_user_messages.is_empty() { + if self.pending_steers.is_empty() && !self.has_queued_follow_up_messages() { return None; } @@ -2266,11 +2328,12 @@ impl ChatWidget { mention_bindings: self.bottom_pane.composer_mention_bindings(), }; - let mut to_merge: Vec = self - .pending_steers - .drain(..) - .map(|steer| steer.user_message) - .collect(); + let mut to_merge: Vec = self.rejected_steers_queue.drain(..).collect(); + to_merge.extend( + self.pending_steers + .drain(..) + .map(|steer| steer.user_message), + ); to_merge.extend(self.queued_user_messages.drain(..)); if !existing_message.text.is_empty() || !existing_message.local_images.is_empty() @@ -2316,14 +2379,17 @@ impl ChatWidget { .iter() .map(|pending| pending.user_message.clone()) .collect(), + rejected_steers_queue: self.rejected_steers_queue.clone(), queued_user_messages: self.queued_user_messages.clone(), current_collaboration_mode: self.current_collaboration_mode.clone(), active_collaboration_mask: self.active_collaboration_mask.clone(), + task_running: self.bottom_pane.is_task_running(), agent_turn_running: self.agent_turn_running, }) } pub(crate) fn restore_thread_input_state(&mut self, input_state: Option) { + let restored_task_running = input_state.as_ref().is_some_and(|state| state.task_running); if let Some(input_state) = input_state { self.current_collaboration_mode = input_state.current_collaboration_mode; self.active_collaboration_mask = input_state.active_collaboration_mask; @@ -2355,13 +2421,24 @@ impl ChatWidget { ); self.bottom_pane.set_composer_pending_pastes(Vec::new()); } - self.pending_steers.clear(); - self.queued_user_messages = input_state.pending_steers; - self.queued_user_messages - .extend(input_state.queued_user_messages); + self.pending_steers = input_state + .pending_steers + .into_iter() + .map(|user_message| PendingSteer { + compare_key: PendingSteerCompareKey { + message: user_message.text.clone(), + image_count: user_message.local_images.len() + + user_message.remote_image_urls.len(), + }, + user_message, + }) + .collect(); + self.rejected_steers_queue = input_state.rejected_steers_queue; + self.queued_user_messages = input_state.queued_user_messages; } else { self.agent_turn_running = false; self.pending_steers.clear(); + self.rejected_steers_queue.clear(); self.set_remote_image_urls(Vec::new()); self.bottom_pane.set_composer_text_with_mention_bindings( String::new(), @@ -2375,6 +2452,10 @@ impl ChatWidget { self.turn_sleep_inhibitor .set_turn_running(self.agent_turn_running); self.update_task_running_state(); + if restored_task_running && !self.bottom_pane.is_task_running() { + self.bottom_pane.set_task_running(/*running*/ true); + self.refresh_terminal_title(); + } self.refresh_pending_input_preview(); self.request_redraw(); } @@ -3612,8 +3693,7 @@ impl ChatWidget { let active_cell = Some(Self::placeholder_session_header_cell(&config)); let current_cwd = Some(config.cwd.clone()); - let queued_message_edit_binding = - queued_message_edit_binding_for_terminal(terminal_info().name); + let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info()); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -3664,6 +3744,7 @@ impl ChatWidget { connectors_partial_snapshot: None, connectors_prefetch_in_flight: false, connectors_force_refetch_pending: false, + pending_mcp_output_requests: 0, plugins_cache: PluginsCacheState::default(), plugins_fetch_state: PluginListFetchState::default(), interrupts: InterruptManager::new(), @@ -3679,6 +3760,7 @@ impl ChatWidget { thread_name: None, forked_from: None, queued_user_messages: VecDeque::new(), + rejected_steers_queue: VecDeque::new(), pending_steers: VecDeque::new(), submit_pending_steers_after_interrupt: false, queued_message_edit_binding, @@ -3738,6 +3820,7 @@ impl ChatWidget { .set_collaboration_modes_enabled(/*enabled*/ true); widget.sync_fast_command_enabled(); widget.sync_personality_command_enabled(); + widget.sync_plugins_command_enabled(); widget .bottom_pane .set_queued_message_edit_binding(widget.queued_message_edit_binding); @@ -3812,8 +3895,7 @@ impl ChatWidget { let active_cell = Some(Self::placeholder_session_header_cell(&config)); let current_cwd = Some(config.cwd.clone()); - let queued_message_edit_binding = - queued_message_edit_binding_for_terminal(terminal_info().name); + let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info()); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -3864,6 +3946,7 @@ impl ChatWidget { connectors_partial_snapshot: None, connectors_prefetch_in_flight: false, connectors_force_refetch_pending: false, + pending_mcp_output_requests: 0, plugins_cache: PluginsCacheState::default(), plugins_fetch_state: PluginListFetchState::default(), interrupts: InterruptManager::new(), @@ -3884,6 +3967,7 @@ impl ChatWidget { plan_delta_buffer: String::new(), plan_item_active: false, queued_user_messages: VecDeque::new(), + rejected_steers_queue: VecDeque::new(), pending_steers: VecDeque::new(), submit_pending_steers_after_interrupt: false, queued_message_edit_binding, @@ -3938,6 +4022,7 @@ impl ChatWidget { .set_collaboration_modes_enabled(/*enabled*/ true); widget.sync_fast_command_enabled(); widget.sync_personality_command_enabled(); + widget.sync_plugins_command_enabled(); widget .bottom_pane .set_queued_message_edit_binding(widget.queued_message_edit_binding); @@ -4004,8 +4089,7 @@ impl ChatWidget { settings: fallback_default, }; - let queued_message_edit_binding = - queued_message_edit_binding_for_terminal(terminal_info().name); + let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info()); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -4056,6 +4140,7 @@ impl ChatWidget { connectors_partial_snapshot: None, connectors_prefetch_in_flight: false, connectors_force_refetch_pending: false, + pending_mcp_output_requests: 0, plugins_cache: PluginsCacheState::default(), plugins_fetch_state: PluginListFetchState::default(), interrupts: InterruptManager::new(), @@ -4071,6 +4156,7 @@ impl ChatWidget { thread_name: None, forked_from: None, queued_user_messages: VecDeque::new(), + rejected_steers_queue: VecDeque::new(), pending_steers: VecDeque::new(), submit_pending_steers_after_interrupt: false, queued_message_edit_binding, @@ -4130,6 +4216,7 @@ impl ChatWidget { .set_collaboration_modes_enabled(/*enabled*/ true); widget.sync_fast_command_enabled(); widget.sync_personality_command_enabled(); + widget.sync_plugins_command_enabled(); widget .bottom_pane .set_queued_message_edit_binding(widget.queued_message_edit_binding); @@ -4212,9 +4299,9 @@ impl ChatWidget { if key_event.kind == KeyEventKind::Press && self.queued_message_edit_binding.is_press(key_event) - && !self.queued_user_messages.is_empty() + && self.has_queued_follow_up_messages() { - if let Some(user_message) = self.queued_user_messages.pop_back() { + if let Some(user_message) = self.pop_latest_queued_user_message() { self.restore_user_message_to_composer(user_message); self.refresh_pending_input_preview(); self.request_redraw(); @@ -4438,6 +4525,9 @@ impl ChatWidget { } SlashCommand::Compact => { self.clear_token_usage(); + if !self.bottom_pane.is_task_running() { + self.bottom_pane.set_task_running(/*running*/ true); + } self.app_event_tx.send(AppEvent::CodexOp(Op::Compact)); } SlashCommand::Review => { @@ -4943,10 +5033,7 @@ impl ChatWidget { } fn queue_user_message(&mut self, user_message: UserMessage) { - if !self.is_session_configured() - || self.bottom_pane.is_task_running() - || self.is_review_mode - { + if !self.is_session_configured() || self.bottom_pane.is_task_running() { self.queued_user_messages.push_back(user_message); self.refresh_pending_input_preview(); } else { @@ -4961,12 +5048,6 @@ impl ChatWidget { self.refresh_pending_input_preview(); return; } - if self.is_review_mode { - self.queued_user_messages.push_back(user_message); - self.refresh_pending_input_preview(); - return; - } - let UserMessage { text, local_images, @@ -5371,7 +5452,9 @@ impl ChatWidget { } EventMsg::TurnComplete(TurnCompleteEvent { last_agent_message, .. - }) => self.on_task_complete(last_agent_message, from_replay), + }) => { + self.on_task_complete(last_agent_message, from_replay); + } EventMsg::TokenCount(ev) => { self.set_token_info(ev.info); self.on_rate_limit_snapshot(ev.rate_limits); @@ -5383,8 +5466,11 @@ impl ChatWidget { message, codex_error_info, }) => { - if let Some(info) = codex_error_info - && let Some(kind) = rate_limit_error_kind(&info) + if codex_error_info + .as_ref() + .is_some_and(|info| self.handle_steer_rejected_error(info)) + { + } else if let Some(kind) = codex_error_info.as_ref().and_then(rate_limit_error_kind) { match kind { RateLimitErrorKind::ServerOverloaded => { @@ -5743,7 +5829,7 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } - if let Some(user_message) = self.queued_user_messages.pop_front() { + if let Some(user_message) = self.pop_next_queued_user_message() { self.submit_user_message(user_message); } // Update the list to reflect the remaining queued messages (if any). @@ -5762,8 +5848,16 @@ impl ChatWidget { .iter() .map(|steer| steer.user_message.text.clone()) .collect(); - self.bottom_pane - .set_pending_input_preview(queued_messages, pending_steers); + let rejected_steers: Vec = self + .rejected_steers_queue + .iter() + .map(|message| message.text.clone()) + .collect(); + self.bottom_pane.set_pending_input_preview( + queued_messages, + pending_steers, + rejected_steers, + ); } pub(crate) fn set_pending_thread_approvals(&mut self, threads: Vec) { @@ -5939,10 +6033,6 @@ impl ChatWidget { self.prefetch_connectors_with_options(force_refetch); } - fn prefetch_connectors(&mut self) { - self.prefetch_connectors_with_options(/*force_refetch*/ false); - } - fn prefetch_connectors_with_options(&mut self, force_refetch: bool) { if !self.connectors_enabled() { return; @@ -7823,6 +7913,7 @@ impl ChatWidget { self.sync_personality_command_enabled(); } if feature == Feature::Plugins { + self.sync_plugins_command_enabled(); self.refresh_plugin_mentions(); } if feature == Feature::PreventIdleSleep { @@ -8025,6 +8116,11 @@ impl ChatWidget { .set_personality_command_enabled(self.config.features.enabled(Feature::Personality)); } + fn sync_plugins_command_enabled(&mut self) { + self.bottom_pane + .set_plugins_command_enabled(self.config.features.enabled(Feature::Plugins)); + } + fn current_model_supports_personality(&self) -> bool { let model = self.current_model(); self.models_manager @@ -8358,8 +8454,8 @@ impl ChatWidget { .is_empty() { self.add_to_history(history_cell::empty_mcp_output()); - } else { - self.submit_op(Op::ListMcpTools); + } else if self.submit_op(Op::ListMcpTools) { + self.pending_mcp_output_requests = self.pending_mcp_output_requests.saturating_add(1); } } @@ -8783,9 +8879,14 @@ impl ChatWidget { #[cfg(test)] pub(crate) fn queued_user_message_texts(&self) -> Vec { - self.queued_user_messages + self.rejected_steers_queue .iter() .map(|message| message.text.clone()) + .chain( + self.queued_user_messages + .iter() + .map(|message| message.text.clone()), + ) .collect() } @@ -8821,13 +8922,77 @@ impl ChatWidget { } fn on_list_mcp_tools(&mut self, ev: McpListToolsResponseEvent) { - self.add_to_history(history_cell::new_mcp_tools_output( - &self.config, - ev.tools, - ev.resources, - ev.resource_templates, - &ev.auth_statuses, - )); + if self.connectors_enabled() { + let mut connectors_by_id: HashMap = HashMap::new(); + for tool in ev.tools.values() { + let Some(meta) = tool.meta.as_ref().and_then(serde_json::Value::as_object) else { + continue; + }; + let Some(connector_id) = meta + .get("connector_id") + .and_then(serde_json::Value::as_str) + .filter(|id| !id.is_empty()) + else { + continue; + }; + connectors_by_id + .entry(connector_id.to_string()) + .or_insert_with(|| { + let name = meta + .get("connector_name") + .or_else(|| meta.get("connector_display_name")) + .and_then(serde_json::Value::as_str) + .filter(|name| !name.trim().is_empty()) + .unwrap_or(connector_id) + .to_string(); + let description = meta + .get("connector_description") + .or_else(|| meta.get("connectorDescription")) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|description| !description.is_empty()) + .map(ToString::to_string); + connectors::AppInfo { + id: connector_id.to_string(), + name, + description, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: true, + is_enabled: true, + plugin_display_names: Vec::new(), + } + }); + } + + let mut app_connectors = connectors_by_id.into_values().collect::>(); + app_connectors.sort_by(|left, right| { + left.name + .cmp(&right.name) + .then_with(|| left.id.cmp(&right.id)) + }); + let app_connectors = connectors::with_app_enabled_state(app_connectors, &self.config); + self.bottom_pane + .set_connectors_snapshot(Some(ConnectorsSnapshot { + connectors: app_connectors, + })); + } + + if self.pending_mcp_output_requests > 0 { + self.pending_mcp_output_requests -= 1; + self.add_to_history(history_cell::new_mcp_tools_output( + &self.config, + ev.tools, + ev.resources, + ev.resource_templates, + &ev.auth_statuses, + )); + } } fn on_list_custom_prompts(&mut self, ev: ListCustomPromptsResponseEvent) { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__compact_queues_user_messages_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__compact_queues_user_messages_snapshot.snap new file mode 100644 index 0000000000..de6cfaddaf --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__compact_queues_user_messages_snapshot.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: term.backend().vt100().screen().contents() +--- + + + + + + + + + +• Working (0s • esc to interrupt) + +• Messages to be submitted at end of turn + ↳ Steer submitted while /compact was running. + +› Ask Codex to do anything + + ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap index 79c08c42ed..e514585661 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap @@ -11,11 +11,11 @@ expression: term.backend().vt100().screen().contents() + • Working (0s • esc to interrupt) -• Queued follow-up messages - ↳ Queued while /review is running. - ⌥ + ↑ edit last queued message +• Messages to be submitted at end of turn + ↳ Steer submitted while /review was running. › Ask Codex to do anything diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index a614d1361d..65daefc917 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -89,6 +89,7 @@ use codex_protocol::protocol::ItemCompletedEvent; use codex_protocol::protocol::McpStartupCompleteEvent; use codex_protocol::protocol::McpStartupStatus; use codex_protocol::protocol::McpStartupUpdateEvent; +use codex_protocol::protocol::NonSteerableTurnKind; use codex_protocol::protocol::Op; use codex_protocol::protocol::PatchApplyBeginEvent; use codex_protocol::protocol::PatchApplyEndEvent; @@ -120,6 +121,8 @@ use codex_protocol::request_user_input::RequestUserInputQuestion; use codex_protocol::request_user_input::RequestUserInputQuestionOption; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; +use codex_terminal_detection::Multiplexer; +use codex_terminal_detection::TerminalInfo; use codex_terminal_detection::TerminalName; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_approval_presets::builtin_approval_presets; @@ -1534,6 +1537,131 @@ async fn entered_review_mode_defaults_to_current_changes_banner() { assert!(chat.is_review_mode); } +#[tokio::test] +async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + chat.handle_codex_event(Event { + id: "turn-start".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); + chat.handle_codex_event(Event { + id: "review-start".into(), + msg: EventMsg::EnteredReviewMode(ReviewRequest { + target: ReviewTarget::BaseBranch { + branch: "feature".to_string(), + }, + user_facing_hint: Some("feature branch".to_string()), + }), + }); + let _ = drain_insert_history(&mut rx); + chat.queued_user_messages + .push_back(UserMessage::from("queued later")); + + chat.submit_user_message(UserMessage::from("review follow-up one")); + chat.submit_user_message(UserMessage::from("review follow-up two")); + + assert_eq!(chat.pending_steers.len(), 2); + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "review follow-up one".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected running-turn steer submit, got {other:?}"), + } + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "review follow-up two".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected second running-turn steer submit, got {other:?}"), + } + + chat.handle_codex_event(Event { + id: "steer-rejected-1".into(), + msg: EventMsg::Error(ErrorEvent { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + }), + }); + chat.handle_codex_event(Event { + id: "steer-rejected-2".into(), + msg: EventMsg::Error(ErrorEvent { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + }), + }); + + assert!(chat.pending_steers.is_empty()); + assert_eq!( + chat.queued_user_message_texts(), + vec![ + "review follow-up one", + "review follow-up two", + "queued later" + ] + ); + assert!(drain_insert_history(&mut rx).is_empty()); + + chat.handle_codex_event(Event { + id: "review-exit".into(), + msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent { + review_output: None, + }), + }); + chat.handle_codex_event(Event { + id: "turn-complete".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: None, + }), + }); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "review follow-up one\nreview follow-up two".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected merged rejected-steer follow-up submit, got {other:?}"), + } + + chat.handle_codex_event(Event { + id: "turn-complete-2".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-2".to_string(), + last_agent_message: None, + }), + }); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "queued later".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected queued draft submit after rejected steers, got {other:?}"), + } +} + #[tokio::test] async fn live_agent_message_renders_during_review_mode() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; @@ -1895,6 +2023,7 @@ async fn make_chatwidget_manual( connectors_partial_snapshot: None, connectors_prefetch_in_flight: false, connectors_force_refetch_pending: false, + pending_mcp_output_requests: 0, plugins_cache: PluginsCacheState::default(), plugins_fetch_state: PluginListFetchState::default(), interrupts: InterruptManager::new(), @@ -1912,6 +2041,7 @@ async fn make_chatwidget_manual( show_welcome_banner: true, startup_tooltip_override: None, queued_user_messages: VecDeque::new(), + rejected_steers_queue: VecDeque::new(), pending_steers: VecDeque::new(), submit_pending_steers_after_interrupt: false, queued_message_edit_binding: crate::key_hint::alt(KeyCode::Up), @@ -3704,9 +3834,11 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() { chat.restore_thread_input_state(Some(ThreadInputState { composer: None, pending_steers: VecDeque::new(), + rejected_steers_queue: VecDeque::new(), queued_user_messages: VecDeque::new(), current_collaboration_mode: chat.current_collaboration_mode.clone(), active_collaboration_mask: chat.active_collaboration_mask.clone(), + task_running: true, agent_turn_running: true, })); @@ -3721,6 +3853,38 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() { assert!(!chat.bottom_pane.is_task_running()); } +#[tokio::test] +async fn restore_thread_input_state_restores_pending_steers_without_downgrading_them() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let mut pending_steers = VecDeque::new(); + pending_steers.push_back(UserMessage::from("pending steer")); + let mut rejected_steers_queue = VecDeque::new(); + rejected_steers_queue.push_back(UserMessage::from("already rejected")); + let mut queued_user_messages = VecDeque::new(); + queued_user_messages.push_back(UserMessage::from("queued draft")); + + chat.restore_thread_input_state(Some(ThreadInputState { + composer: None, + pending_steers, + rejected_steers_queue, + queued_user_messages, + current_collaboration_mode: chat.current_collaboration_mode.clone(), + active_collaboration_mask: chat.active_collaboration_mask.clone(), + task_running: false, + agent_turn_running: false, + })); + + assert_eq!( + chat.queued_user_message_texts(), + vec!["already rejected", "queued draft"] + ); + assert_eq!(chat.pending_steers.len(), 1); + assert_eq!( + chat.pending_steers.front().unwrap().user_message.text, + "pending steer" + ); +} + #[tokio::test] async fn alt_up_edits_most_recent_queued_message() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -3755,10 +3919,10 @@ async fn alt_up_edits_most_recent_queued_message() { } async fn assert_shift_left_edits_most_recent_queued_message_for_terminal( - terminal_name: TerminalName, + terminal_info: TerminalInfo, ) { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - chat.queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_name); + chat.queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info); chat.bottom_pane .set_queued_message_edit_binding(chat.queued_message_edit_binding); @@ -3790,37 +3954,102 @@ async fn assert_shift_left_edits_most_recent_queued_message_for_terminal( #[tokio::test] async fn shift_left_edits_most_recent_queued_message_in_apple_terminal() { - assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalName::AppleTerminal) - .await; + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { + name: TerminalName::AppleTerminal, + term_program: None, + version: None, + term: None, + multiplexer: None, + }) + .await; } #[tokio::test] async fn shift_left_edits_most_recent_queued_message_in_warp_terminal() { - assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalName::WarpTerminal) - .await; + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { + name: TerminalName::WarpTerminal, + term_program: None, + version: None, + term: None, + multiplexer: None, + }) + .await; } #[tokio::test] async fn shift_left_edits_most_recent_queued_message_in_vscode_terminal() { - assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalName::VsCode).await; + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { + name: TerminalName::VsCode, + term_program: None, + version: None, + term: None, + multiplexer: None, + }) + .await; +} + +#[tokio::test] +async fn shift_left_edits_most_recent_queued_message_in_tmux() { + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { + name: TerminalName::Iterm2, + term_program: None, + version: None, + term: None, + multiplexer: Some(Multiplexer::Tmux { version: None }), + }) + .await; } #[test] -fn queued_message_edit_binding_mapping_covers_special_terminals() { +fn queued_message_edit_binding_mapping_covers_special_terminals_and_tmux() { assert_eq!( - queued_message_edit_binding_for_terminal(TerminalName::AppleTerminal), + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::AppleTerminal, + term_program: None, + version: None, + term: None, + multiplexer: None, + }), crate::key_hint::shift(KeyCode::Left) ); assert_eq!( - queued_message_edit_binding_for_terminal(TerminalName::WarpTerminal), + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::WarpTerminal, + term_program: None, + version: None, + term: None, + multiplexer: None, + }), crate::key_hint::shift(KeyCode::Left) ); assert_eq!( - queued_message_edit_binding_for_terminal(TerminalName::VsCode), + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::VsCode, + term_program: None, + version: None, + term: None, + multiplexer: None, + }), crate::key_hint::shift(KeyCode::Left) ); assert_eq!( - queued_message_edit_binding_for_terminal(TerminalName::Iterm2), + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::Iterm2, + term_program: None, + version: None, + term: None, + multiplexer: Some(Multiplexer::Tmux { version: None }), + }), + crate::key_hint::shift(KeyCode::Left) + ); + assert_eq!( + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::Iterm2, + term_program: None, + version: None, + term: None, + multiplexer: None, + }), crate::key_hint::alt(KeyCode::Up) ); } @@ -4060,6 +4289,97 @@ async fn steer_enter_queues_while_plan_stream_is_active() { assert!(drain_insert_history(&mut rx).is_empty()); } +#[tokio::test] +async fn submit_user_message_queues_while_compaction_turn_is_running() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + chat.handle_codex_event(Event { + id: "turn-started".to_string(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); + + chat.submit_user_message(UserMessage::from("queued while compacting")); + + assert_eq!(chat.pending_steers.len(), 1); + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "queued while compacting".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected running-turn compact steer submit, got {other:?}"), + } + + chat.handle_codex_event(Event { + id: "steer-rejected".into(), + msg: EventMsg::Error(ErrorEvent { + message: "cannot steer a compact turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }), + }), + }); + + assert!(chat.pending_steers.is_empty()); + assert_eq!( + chat.queued_user_message_texts(), + vec!["queued while compacting"] + ); + + chat.handle_codex_event(Event { + id: "turn-complete".to_string(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: None, + }), + }); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "queued while compacting".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected queued compact follow-up Op::UserTurn, got {other:?}"), + } +} + +#[tokio::test] +async fn slash_compact_eagerly_queues_follow_up_before_turn_start() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + + chat.dispatch_command(SlashCommand::Compact); + + assert!(chat.bottom_pane.is_task_running()); + match rx.try_recv() { + Ok(AppEvent::CodexOp(Op::Compact)) => {} + other => panic!("expected compact op to be submitted, got {other:?}"), + } + + chat.bottom_pane.set_composer_text( + "queued before compact turn start".to_string(), + Vec::new(), + Vec::new(), + ); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(chat.pending_steers.is_empty()); + assert_eq!(chat.queued_user_messages.len(), 1); + assert_eq!( + chat.queued_user_messages.front().unwrap().text, + "queued before compact turn start" + ); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); +} + #[tokio::test] async fn steer_enter_uses_pending_steers_while_turn_is_running_without_streaming() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; @@ -11500,9 +11820,17 @@ async fn chatwidget_tall() { } #[tokio::test] -async fn enter_queues_user_messages_while_review_is_running() { +async fn enter_submits_steer_while_review_is_running() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; chat.thread_id = Some(ThreadId::new()); + chat.handle_codex_event(Event { + id: "turn-start".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); chat.handle_codex_event(Event { id: "review-1".into(), @@ -11514,19 +11842,28 @@ async fn enter_queues_user_messages_while_review_is_running() { let _ = drain_insert_history(&mut rx); chat.bottom_pane.set_composer_text( - "Queued while /review is running.".to_string(), + "Steer submitted while /review was running.".to_string(), Vec::new(), Vec::new(), ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(chat.queued_user_messages.len(), 1); + assert!(chat.queued_user_messages.is_empty()); + assert_eq!(chat.pending_steers.len(), 1); assert_eq!( - chat.queued_user_messages.front().unwrap().text, - "Queued while /review is running." + chat.pending_steers.front().unwrap().user_message.text, + "Steer submitted while /review was running." ); - assert!(chat.pending_steers.is_empty()); - assert_no_submit_op(&mut op_rx); + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "Steer submitted while /review was running.".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected running-turn steer submit, got {other:?}"), + } assert!(drain_insert_history(&mut rx).is_empty()); } @@ -11534,6 +11871,14 @@ async fn enter_queues_user_messages_while_review_is_running() { async fn review_queues_user_messages_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; chat.thread_id = Some(ThreadId::new()); + chat.handle_codex_event(Event { + id: "turn-start".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); chat.handle_codex_event(Event { id: "review-1".into(), @@ -11544,9 +11889,57 @@ async fn review_queues_user_messages_snapshot() { }); let _ = drain_insert_history(&mut rx); - chat.queue_user_message(UserMessage::from( - "Queued while /review is running.".to_string(), + chat.submit_user_message(UserMessage::from( + "Steer submitted while /review was running.".to_string(), )); + chat.handle_codex_event(Event { + id: "steer-rejected".into(), + msg: EventMsg::Error(ErrorEvent { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + }), + }); + + let width: u16 = 80; + let height: u16 = 18; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + let desired_height = chat.desired_height(width).min(height); + term.set_viewport_area(Rect::new(0, height - desired_height, width, desired_height)); + term.draw(|f| { + chat.render(f.area(), f.buffer_mut()); + }) + .unwrap(); + assert_snapshot!(term.backend().vt100().screen().contents()); +} + +#[tokio::test] +async fn compact_queues_user_messages_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + chat.handle_codex_event(Event { + id: "turn-start".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); + + chat.submit_user_message(UserMessage::from( + "Steer submitted while /compact was running.".to_string(), + )); + chat.handle_codex_event(Event { + id: "steer-rejected".into(), + msg: EventMsg::Error(ErrorEvent { + message: "cannot steer a compact turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }), + }), + }); let width: u16 = 80; let height: u16 = 18; diff --git a/codex-rs/tui_app_server/src/app.rs b/codex-rs/tui_app_server/src/app.rs index 171df69270..e00a9604f1 100644 --- a/codex-rs/tui_app_server/src/app.rs +++ b/codex-rs/tui_app_server/src/app.rs @@ -48,7 +48,9 @@ use crate::update_action::UpdateAction; use crate::version::CODEX_CLI_VERSION; use codex_ansi_escape::ansi_escape_line; use codex_app_server_client::AppServerRequestHandle; +use codex_app_server_client::TypedRequestError; use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; use codex_app_server_protocol::ConfigLayerSource; use codex_app_server_protocol::ListMcpServerStatusParams; use codex_app_server_protocol::ListMcpServerStatusResponse; @@ -63,6 +65,7 @@ use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::ThreadRollbackResponse; use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStatus; use codex_core::config::Config; use codex_core::config::ConfigBuilder; @@ -435,8 +438,8 @@ fn emit_project_config_warnings(app_event_tx: &AppEventSender, config: &Config) ))); } -fn emit_missing_system_bwrap_warning(app_event_tx: &AppEventSender) { - let Some(message) = codex_core::config::missing_system_bwrap_warning() else { +fn emit_system_bwrap_warning(app_event_tx: &AppEventSender) { + let Some(message) = codex_core::config::system_bwrap_warning() else { return; }; @@ -464,8 +467,6 @@ enum ThreadBufferedEvent { Notification(ServerNotification), Request(ServerRequest), HistoryEntryResponse(GetHistoryEntryResponseEvent), - LegacyWarning(String), - LegacyRollback { num_turns: u32 }, } #[derive(Debug)] @@ -474,7 +475,6 @@ struct ThreadEventStore { turns: Vec, buffer: VecDeque, pending_interactive_replay: PendingInteractiveReplayState, - pending_local_legacy_rollbacks: VecDeque, active_turn_id: Option, input_state: Option, capacity: usize, @@ -483,10 +483,7 @@ struct ThreadEventStore { impl ThreadEventStore { fn event_survives_session_refresh(event: &ThreadBufferedEvent) -> bool { - matches!( - event, - ThreadBufferedEvent::Request(_) | ThreadBufferedEvent::LegacyWarning(_) - ) + matches!(event, ThreadBufferedEvent::Request(_)) } fn new(capacity: usize) -> Self { @@ -495,7 +492,6 @@ impl ThreadEventStore { turns: Vec::new(), buffer: VecDeque::new(), pending_interactive_replay: PendingInteractiveReplayState::default(), - pending_local_legacy_rollbacks: VecDeque::new(), active_turn_id: None, input_state: None, capacity, @@ -521,7 +517,6 @@ impl ThreadEventStore { } fn set_turns(&mut self, turns: Vec) { - self.pending_local_legacy_rollbacks.clear(); self.active_turn_id = turns .iter() .rev() @@ -578,37 +573,6 @@ impl ThreadEventStore { self.active_turn_id = None; } - fn note_local_thread_rollback(&mut self, num_turns: u32) { - self.pending_local_legacy_rollbacks.push_back(num_turns); - while self.pending_local_legacy_rollbacks.len() > self.capacity { - self.pending_local_legacy_rollbacks.pop_front(); - } - } - - fn consume_pending_local_legacy_rollback(&mut self, num_turns: u32) -> bool { - match self.pending_local_legacy_rollbacks.front() { - Some(pending_num_turns) if *pending_num_turns == num_turns => { - self.pending_local_legacy_rollbacks.pop_front(); - true - } - _ => false, - } - } - - fn apply_legacy_thread_rollback(&mut self, num_turns: u32) { - let num_turns = usize::try_from(num_turns).unwrap_or(usize::MAX); - if num_turns >= self.turns.len() { - self.turns.clear(); - } else { - self.turns - .truncate(self.turns.len().saturating_sub(num_turns)); - } - self.buffer.clear(); - self.pending_interactive_replay = PendingInteractiveReplayState::default(); - self.pending_local_legacy_rollbacks.clear(); - self.active_turn_id = None; - } - fn snapshot(&self) -> ThreadEventSnapshot { ThreadEventSnapshot { session: self.session.clone(), @@ -623,9 +587,7 @@ impl ThreadEventStore { .pending_interactive_replay .should_replay_snapshot_request(request), ThreadBufferedEvent::Notification(_) - | ThreadBufferedEvent::HistoryEntryResponse(_) - | ThreadBufferedEvent::LegacyWarning(_) - | ThreadBufferedEvent::LegacyRollback { .. } => true, + | ThreadBufferedEvent::HistoryEntryResponse(_) => true, }) .cloned() .collect(), @@ -1008,6 +970,18 @@ fn normalize_harness_overrides_for_cwd( Ok(overrides) } +fn active_turn_not_steerable_turn_error(error: &TypedRequestError) -> Option { + let TypedRequestError::Server { source, .. } = error else { + return None; + }; + let turn_error: AppServerTurnError = serde_json::from_value(source.data.clone()?).ok()?; + matches!( + turn_error.codex_error_info, + Some(AppServerCodexErrorInfo::ActiveTurnNotSteerable { .. }) + ) + .then_some(turn_error) +} + impl App { pub fn chatwidget_init_for_forked_or_resumed_thread( &self, @@ -1991,9 +1965,21 @@ impl App { personality, } => { if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { - app_server + match app_server .turn_steer(thread_id, turn_id, items.to_vec()) - .await?; + .await + { + Ok(_) => {} + Err(error) => { + if let Some(turn_error) = active_turn_not_steerable_turn_error(&error) { + if !self.chat_widget.enqueue_rejected_steer() { + self.chat_widget.add_error_message(turn_error.message); + } + } else { + return Err(error.into()); + } + } + } } else { app_server .turn_start( @@ -2283,50 +2269,6 @@ impl App { Ok(()) } - async fn enqueue_thread_legacy_warning( - &mut self, - thread_id: ThreadId, - message: String, - ) -> Result<()> { - let (sender, store) = { - let channel = self.ensure_thread_channel(thread_id); - (channel.sender.clone(), Arc::clone(&channel.store)) - }; - - let should_send = { - let mut guard = store.lock().await; - guard - .buffer - .push_back(ThreadBufferedEvent::LegacyWarning(message.clone())); - if guard.buffer.len() > guard.capacity - && let Some(removed) = guard.buffer.pop_front() - && let ThreadBufferedEvent::Request(request) = &removed - { - guard - .pending_interactive_replay - .note_evicted_server_request(request); - } - guard.active - }; - - if should_send { - match sender.try_send(ThreadBufferedEvent::LegacyWarning(message)) { - Ok(()) => {} - Err(TrySendError::Full(event)) => { - tokio::spawn(async move { - if let Err(err) = sender.send(event).await { - tracing::warn!("thread {thread_id} event channel closed: {err}"); - } - }); - } - Err(TrySendError::Closed(_)) => { - tracing::warn!("thread {thread_id} event channel closed"); - } - } - } - Ok(()) - } - async fn enqueue_thread_history_entry_response( &mut self, thread_id: ThreadId, @@ -2371,64 +2313,6 @@ impl App { Ok(()) } - async fn enqueue_thread_legacy_rollback( - &mut self, - thread_id: ThreadId, - num_turns: u32, - ) -> Result<()> { - let (sender, store) = { - let channel = self.ensure_thread_channel(thread_id); - (channel.sender.clone(), Arc::clone(&channel.store)) - }; - - let should_send = { - let mut guard = store.lock().await; - if guard.consume_pending_local_legacy_rollback(num_turns) { - false - } else { - guard.apply_legacy_thread_rollback(num_turns); - guard.active - } - }; - - if should_send { - match sender.try_send(ThreadBufferedEvent::LegacyRollback { num_turns }) { - Ok(()) => {} - Err(TrySendError::Full(event)) => { - tokio::spawn(async move { - if let Err(err) = sender.send(event).await { - tracing::warn!("thread {thread_id} event channel closed: {err}"); - } - }); - } - Err(TrySendError::Closed(_)) => { - tracing::warn!("thread {thread_id} event channel closed"); - } - } - } - Ok(()) - } - - async fn enqueue_primary_thread_legacy_warning(&mut self, message: String) -> Result<()> { - if let Some(thread_id) = self.primary_thread_id { - return self.enqueue_thread_legacy_warning(thread_id, message).await; - } - self.pending_primary_events - .push_back(ThreadBufferedEvent::LegacyWarning(message)); - Ok(()) - } - - async fn enqueue_primary_thread_legacy_rollback(&mut self, num_turns: u32) -> Result<()> { - if let Some(thread_id) = self.primary_thread_id { - return self - .enqueue_thread_legacy_rollback(thread_id, num_turns) - .await; - } - self.pending_primary_events - .push_back(ThreadBufferedEvent::LegacyRollback { num_turns }); - Ok(()) - } - async fn enqueue_primary_thread_session( &mut self, session: ThreadSessionState, @@ -2466,14 +2350,6 @@ impl App { self.enqueue_thread_history_entry_response(thread_id, event) .await?; } - ThreadBufferedEvent::LegacyWarning(message) => { - self.enqueue_thread_legacy_warning(thread_id, message) - .await?; - } - ThreadBufferedEvent::LegacyRollback { num_turns } => { - self.enqueue_thread_legacy_rollback(thread_id, num_turns) - .await?; - } } } self.chat_widget @@ -2945,7 +2821,7 @@ impl App { let (app_event_tx, mut app_event_rx) = unbounded_channel(); let app_event_tx = AppEventSender::new(app_event_tx); emit_project_config_warnings(&app_event_tx, &config); - emit_missing_system_bwrap_warning(&app_event_tx); + emit_system_bwrap_warning(&app_event_tx); tui.set_notification_method(config.tui_notification_method); let harness_overrides = @@ -4769,7 +4645,6 @@ impl App { if let Some(channel) = self.thread_event_channels.get(&thread_id) { let mut store = channel.store.lock().await; store.apply_thread_rollback(response); - store.note_local_thread_rollback(num_turns); } if self.active_thread_id == Some(thread_id) && let Some(mut rx) = self.active_thread_rx.take() @@ -4814,13 +4689,6 @@ impl App { ThreadBufferedEvent::HistoryEntryResponse(event) => { self.chat_widget.handle_history_entry_response(event); } - ThreadBufferedEvent::LegacyWarning(message) => { - self.chat_widget.add_warning_message(message); - } - ThreadBufferedEvent::LegacyRollback { num_turns } => { - self.handle_backtrack_rollback_succeeded(num_turns); - self.chat_widget.handle_thread_rolled_back(); - } } if needs_refresh { self.refresh_status_line(); @@ -4838,13 +4706,6 @@ impl App { ThreadBufferedEvent::HistoryEntryResponse(event) => { self.chat_widget.handle_history_entry_response(event) } - ThreadBufferedEvent::LegacyWarning(message) => { - self.chat_widget.add_warning_message(message); - } - ThreadBufferedEvent::LegacyRollback { num_turns } => { - self.handle_backtrack_rollback_succeeded(num_turns); - self.chat_widget.handle_thread_rolled_back(); - } } } @@ -5335,10 +5196,13 @@ mod tests { use codex_app_server_protocol::AdditionalPermissionProfile; use codex_app_server_protocol::AgentMessageDeltaNotification; use codex_app_server_protocol::CommandExecutionRequestApprovalParams; + use codex_app_server_protocol::ConfigWarningNotification; + use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::NetworkApprovalContext as AppServerNetworkApprovalContext; use codex_app_server_protocol::NetworkApprovalProtocol as AppServerNetworkApprovalProtocol; use codex_app_server_protocol::NetworkPolicyAmendment as AppServerNetworkPolicyAmendment; use codex_app_server_protocol::NetworkPolicyRuleAction as AppServerNetworkPolicyRuleAction; + use codex_app_server_protocol::NonSteerableTurnKind as AppServerNonSteerableTurnKind; use codex_app_server_protocol::RequestId as AppServerRequestId; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; @@ -5351,6 +5215,7 @@ mod tests { use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; + use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput as AppServerUserInput; @@ -5900,7 +5765,7 @@ mod tests { app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() @@ -5935,33 +5800,6 @@ mod tests { } } - #[tokio::test] - async fn replay_thread_snapshot_replays_legacy_warning_history() { - let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; - - app.replay_thread_snapshot( - ThreadEventSnapshot { - session: None, - turns: Vec::new(), - events: vec![ThreadBufferedEvent::LegacyWarning( - "legacy warning message".to_string(), - )], - input_state: None, - }, - false, - ); - - let mut saw_warning = false; - while let Ok(event) = app_event_rx.try_recv() { - if let AppEvent::InsertHistoryCell(cell) = event { - let transcript = lines_to_single_string(&cell.transcript_lines(80)); - saw_warning |= transcript.contains("legacy warning message"); - } - } - - assert!(saw_warning, "expected replayed legacy warning history cell"); - } - #[tokio::test] async fn replay_only_thread_keeps_restored_queue_visible() { let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; @@ -5977,7 +5815,7 @@ mod tests { app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() @@ -6026,7 +5864,7 @@ mod tests { app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() @@ -6073,7 +5911,7 @@ mod tests { app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() @@ -6143,7 +5981,7 @@ mod tests { app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() @@ -7308,87 +7146,6 @@ guardian_approval = true Ok(()) } - #[tokio::test] - async fn legacy_warning_eviction_clears_pending_interactive_replay_state() -> Result<()> { - let mut app = make_test_app().await; - let thread_id = ThreadId::new(); - let channel = ThreadEventChannel::new(1); - { - let mut store = channel.store.lock().await; - store.push_request(exec_approval_request( - thread_id, - "turn-approval", - "call-approval", - None, - )); - assert_eq!(store.has_pending_thread_approvals(), true); - } - app.thread_event_channels.insert(thread_id, channel); - - app.enqueue_thread_legacy_warning(thread_id, "legacy warning".to_string()) - .await?; - - let store = app - .thread_event_channels - .get(&thread_id) - .expect("thread store should exist") - .store - .lock() - .await; - assert_eq!(store.has_pending_thread_approvals(), false); - let snapshot = store.snapshot(); - assert_eq!(snapshot.events.len(), 1); - assert!(matches!( - snapshot.events.first(), - Some(ThreadBufferedEvent::LegacyWarning(message)) if message == "legacy warning" - )); - - Ok(()) - } - - #[tokio::test] - async fn legacy_thread_rollback_trims_inactive_thread_snapshot_state() -> Result<()> { - let mut app = make_test_app().await; - let thread_id = ThreadId::new(); - let session = test_thread_session(thread_id, PathBuf::from("/tmp/project")); - let turns = vec![ - test_turn("turn-1", TurnStatus::Completed, Vec::new()), - test_turn("turn-2", TurnStatus::Completed, Vec::new()), - ]; - let channel = ThreadEventChannel::new_with_session(4, session, turns); - { - let mut store = channel.store.lock().await; - store.push_request(exec_approval_request( - thread_id, - "turn-approval", - "call-approval", - None, - )); - assert_eq!(store.has_pending_thread_approvals(), true); - } - app.thread_event_channels.insert(thread_id, channel); - - app.enqueue_thread_legacy_rollback(thread_id, 1).await?; - - let store = app - .thread_event_channels - .get(&thread_id) - .expect("thread store should exist") - .store - .lock() - .await; - assert_eq!( - store.turns, - vec![test_turn("turn-1", TurnStatus::Completed, Vec::new())] - ); - assert_eq!(store.has_pending_thread_approvals(), false); - let snapshot = store.snapshot(); - assert_eq!(snapshot.turns, store.turns); - assert!(snapshot.events.is_empty()); - - Ok(()) - } - #[tokio::test] async fn inactive_thread_started_notification_initializes_replay_session() -> Result<()> { let mut app = make_test_app().await; @@ -8108,16 +7865,6 @@ guardian_approval = true assert_eq!(store.has_pending_thread_approvals(), false); } - #[test] - fn thread_event_store_consumes_matching_local_legacy_rollback_once() { - let mut store = ThreadEventStore::new(8); - store.note_local_thread_rollback(2); - - assert!(store.consume_pending_local_legacy_rollback(2)); - assert!(!store.consume_pending_local_legacy_rollback(2)); - assert!(!store.consume_pending_local_legacy_rollback(1)); - } - fn next_user_turn_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> Op { let mut seen = Vec::new(); while let Ok(op) = op_rx.try_recv() { @@ -8300,6 +8047,30 @@ guardian_approval = true ); } + #[test] + fn active_turn_not_steerable_turn_error_extracts_structured_server_error() { + let turn_error = AppServerTurnError { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(AppServerCodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: AppServerNonSteerableTurnKind::Review, + }), + additional_details: None, + }; + let error = TypedRequestError::Server { + method: "turn/steer".to_string(), + source: JSONRPCErrorError { + code: -32602, + message: turn_error.message.clone(), + data: Some(serde_json::to_value(&turn_error).expect("turn error should serialize")), + }, + }; + + assert_eq!( + active_turn_not_steerable_turn_error(&error), + Some(turn_error) + ); + } + #[test] fn select_model_availability_nux_uses_existing_model_order_as_priority() { let mut presets = all_model_presets(); @@ -9076,8 +8847,13 @@ guardian_approval = true let (tx, rx) = mpsc::channel(8); app.active_thread_id = Some(thread_id); app.active_thread_rx = Some(rx); - tx.send(ThreadBufferedEvent::LegacyWarning( - "stale warning".to_string(), + tx.send(ThreadBufferedEvent::Notification( + ServerNotification::ConfigWarning(ConfigWarningNotification { + summary: "stale warning".to_string(), + details: None, + path: None, + range: None, + }), )) .await .expect("event should queue"); @@ -9115,62 +8891,6 @@ guardian_approval = true assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty))); } - #[tokio::test] - async fn local_rollback_response_suppresses_matching_legacy_rollback() { - let mut app = make_test_app().await; - let thread_id = ThreadId::new(); - let session = test_thread_session(thread_id, PathBuf::from("/tmp/project")); - let initial_turns = vec![ - test_turn("turn-1", TurnStatus::Completed, Vec::new()), - test_turn("turn-2", TurnStatus::Completed, Vec::new()), - ]; - app.thread_event_channels.insert( - thread_id, - ThreadEventChannel::new_with_session(8, session, initial_turns), - ); - - app.handle_thread_rollback_response( - thread_id, - 1, - &ThreadRollbackResponse { - thread: Thread { - id: thread_id.to_string(), - preview: String::new(), - ephemeral: false, - model_provider: "openai".to_string(), - created_at: 0, - updated_at: 0, - status: codex_app_server_protocol::ThreadStatus::Idle, - path: None, - cwd: PathBuf::from("/tmp/project"), - cli_version: "0.0.0".to_string(), - source: SessionSource::Cli.into(), - agent_nickname: None, - agent_role: None, - git_info: None, - name: None, - turns: vec![test_turn("turn-1", TurnStatus::Completed, Vec::new())], - }, - }, - ) - .await; - - app.enqueue_thread_legacy_rollback(thread_id, 1) - .await - .expect("legacy rollback should not fail"); - - let store = app - .thread_event_channels - .get(&thread_id) - .expect("thread channel") - .store - .lock() - .await; - let snapshot = store.snapshot(); - assert_eq!(snapshot.turns.len(), 1); - assert!(snapshot.events.is_empty()); - } - #[tokio::test] async fn new_session_requests_shutdown_for_previous_conversation() { let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; diff --git a/codex-rs/tui_app_server/src/app/app_server_adapter.rs b/codex-rs/tui_app_server/src/app/app_server_adapter.rs index 0d21128538..952a9bf7b8 100644 --- a/codex-rs/tui_app_server/src/app/app_server_adapter.rs +++ b/codex-rs/tui_app_server/src/app/app_server_adapter.rs @@ -16,13 +16,9 @@ use crate::app_event::AppEvent; use crate::app_server_session::AppServerSession; use crate::app_server_session::app_server_rate_limit_snapshot_to_core; use crate::app_server_session::status_account_display_from_auth_mode; -use crate::local_chatgpt_auth::load_local_chatgpt_auth; use codex_app_server_client::AppServerEvent; use codex_app_server_protocol::AuthMode; -use codex_app_server_protocol::ChatgptAuthTokensRefreshParams; use codex_app_server_protocol::JSONRPCErrorError; -use codex_app_server_protocol::JSONRPCNotification; -use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; #[cfg(test)] @@ -106,16 +102,9 @@ use codex_protocol::protocol::TurnAbortedEvent; use codex_protocol::protocol::TurnCompleteEvent; #[cfg(test)] use codex_protocol::protocol::TurnStartedEvent; -use serde_json::Value; #[cfg(test)] use std::time::Duration; -#[derive(Debug, PartialEq, Eq)] -enum LegacyThreadNotification { - Warning(String), - Rollback { num_turns: u32 }, -} - impl App { pub(super) async fn handle_app_server_event( &mut self, @@ -133,48 +122,10 @@ impl App { self.handle_server_notification_event(app_server_client, notification) .await; } - AppServerEvent::LegacyNotification(notification) => { - if let Some((thread_id, legacy_notification)) = - legacy_thread_notification(notification) - { - let result = match legacy_notification { - LegacyThreadNotification::Warning(message) => { - if self.primary_thread_id == Some(thread_id) - || self.primary_thread_id.is_none() - { - self.enqueue_primary_thread_legacy_warning(message).await - } else { - self.enqueue_thread_legacy_warning(thread_id, message).await - } - } - LegacyThreadNotification::Rollback { num_turns } => { - if self.primary_thread_id == Some(thread_id) - || self.primary_thread_id.is_none() - { - self.enqueue_primary_thread_legacy_rollback(num_turns).await - } else { - self.enqueue_thread_legacy_rollback(thread_id, num_turns) - .await - } - } - }; - if let Err(err) = result { - tracing::warn!("failed to enqueue app-server legacy notification: {err}"); - } - } else { - tracing::debug!("ignoring legacy app-server notification in tui_app_server"); - } + AppServerEvent::LegacyNotification(_) => { + tracing::debug!("ignoring legacy app-server notification in tui_app_server"); } AppServerEvent::ServerRequest(request) => { - if let ServerRequest::ChatgptAuthTokensRefresh { request_id, params } = request { - self.handle_chatgpt_auth_tokens_refresh_request( - app_server_client, - request_id, - params, - ) - .await; - return; - } self.handle_server_request_event(app_server_client, request) .await; } @@ -293,71 +244,6 @@ impl App { tracing::warn!("failed to enqueue app-server request: {err}"); } } - - async fn handle_chatgpt_auth_tokens_refresh_request( - &mut self, - app_server_client: &AppServerSession, - request_id: RequestId, - params: ChatgptAuthTokensRefreshParams, - ) { - let config = self.config.clone(); - let result = tokio::task::spawn_blocking(move || { - resolve_chatgpt_auth_tokens_refresh_response( - &config.codex_home, - config.cli_auth_credentials_store_mode, - config.forced_chatgpt_workspace_id.as_deref(), - ¶ms, - ) - }) - .await; - - match result { - Ok(Ok(response)) => { - let response = serde_json::to_value(response).map_err(|err| { - format!("failed to serialize chatgpt auth refresh response: {err}") - }); - match response { - Ok(response) => { - if let Err(err) = app_server_client - .resolve_server_request(request_id, response) - .await - { - tracing::warn!("failed to resolve chatgpt auth refresh request: {err}"); - } - } - Err(err) => { - self.chat_widget.add_error_message(err.clone()); - if let Err(reject_err) = self - .reject_app_server_request(app_server_client, request_id, err) - .await - { - tracing::warn!("{reject_err}"); - } - } - } - } - Ok(Err(err)) => { - self.chat_widget.add_error_message(err.clone()); - if let Err(reject_err) = self - .reject_app_server_request(app_server_client, request_id, err) - .await - { - tracing::warn!("{reject_err}"); - } - } - Err(err) => { - let message = format!("chatgpt auth refresh task failed: {err}"); - self.chat_widget.add_error_message(message.clone()); - if let Err(reject_err) = self - .reject_app_server_request(app_server_client, request_id, message) - .await - { - tracing::warn!("{reject_err}"); - } - } - } - } - async fn reject_app_server_request( &self, app_server_client: &AppServerSession, @@ -482,6 +368,9 @@ fn server_notification_thread_target( ServerNotification::ThreadRealtimeItemAdded(notification) => { Some(notification.thread_id.as_str()) } + ServerNotification::ThreadRealtimeTranscriptUpdated(notification) => { + Some(notification.thread_id.as_str()) + } ServerNotification::ThreadRealtimeOutputAudioDelta(notification) => { Some(notification.thread_id.as_str()) } @@ -516,28 +405,6 @@ fn server_notification_thread_target( } } -fn resolve_chatgpt_auth_tokens_refresh_response( - codex_home: &std::path::Path, - auth_credentials_store_mode: codex_core::auth::AuthCredentialsStoreMode, - forced_chatgpt_workspace_id: Option<&str>, - params: &ChatgptAuthTokensRefreshParams, -) -> Result { - let auth = load_local_chatgpt_auth( - codex_home, - auth_credentials_store_mode, - forced_chatgpt_workspace_id, - )?; - if let Some(previous_account_id) = params.previous_account_id.as_deref() - && previous_account_id != auth.chatgpt_account_id - { - return Err(format!( - "local ChatGPT auth refresh account mismatch: expected `{previous_account_id}`, got `{}`", - auth.chatgpt_account_id - )); - } - Ok(auth.to_refresh_response()) -} - #[cfg(test)] /// Convert a `Thread` snapshot into a flat sequence of protocol `Event`s /// suitable for replaying into the TUI event store. @@ -564,48 +431,6 @@ pub(super) fn thread_snapshot_events( .collect() } -fn legacy_thread_notification( - notification: JSONRPCNotification, -) -> Option<(ThreadId, LegacyThreadNotification)> { - let method = notification - .method - .strip_prefix("codex/event/") - .unwrap_or(¬ification.method); - - let Value::Object(mut params) = notification.params? else { - return None; - }; - let thread_id = params - .remove("conversationId") - .and_then(|value| serde_json::from_value::(value).ok()) - .and_then(|value| ThreadId::from_string(&value).ok())?; - let msg = params.get("msg").and_then(Value::as_object)?; - - match method { - "warning" => { - let message = msg - .get("type") - .and_then(Value::as_str) - .zip(msg.get("message")) - .and_then(|(kind, message)| (kind == "warning").then_some(message)) - .and_then(Value::as_str) - .map(ToOwned::to_owned)?; - Some((thread_id, LegacyThreadNotification::Warning(message))) - } - "thread_rolled_back" => { - let num_turns = msg - .get("type") - .and_then(Value::as_str) - .zip(msg.get("num_turns")) - .and_then(|(kind, num_turns)| (kind == "thread_rolled_back").then_some(num_turns)) - .and_then(Value::as_u64) - .and_then(|num_turns| u32::try_from(num_turns).ok())?; - Some((thread_id, LegacyThreadNotification::Rollback { num_turns })) - } - _ => None, - } -} - #[cfg(test)] fn server_notification_thread_events( notification: ServerNotification, @@ -1150,113 +975,6 @@ fn split_command_string(command: &str) -> Vec { } } -#[cfg(test)] -mod refresh_tests { - use super::*; - - use base64::Engine; - use chrono::Utc; - use codex_app_server_protocol::AuthMode; - use codex_core::auth::AuthCredentialsStoreMode; - use codex_core::auth::AuthDotJson; - use codex_core::auth::save_auth; - use codex_core::token_data::TokenData; - use pretty_assertions::assert_eq; - use serde::Serialize; - use serde_json::json; - use tempfile::TempDir; - - fn fake_jwt(account_id: &str, plan_type: &str) -> String { - #[derive(Serialize)] - struct Header { - alg: &'static str, - typ: &'static str, - } - - let header = Header { - alg: "none", - typ: "JWT", - }; - let payload = json!({ - "email": "user@example.com", - "https://api.openai.com/auth": { - "chatgpt_account_id": account_id, - "chatgpt_plan_type": plan_type, - }, - }); - let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); - let header_b64 = encode(&serde_json::to_vec(&header).expect("serialize header")); - let payload_b64 = encode(&serde_json::to_vec(&payload).expect("serialize payload")); - let signature_b64 = encode(b"sig"); - format!("{header_b64}.{payload_b64}.{signature_b64}") - } - - fn write_chatgpt_auth(codex_home: &std::path::Path) { - let id_token = fake_jwt("workspace-1", "business"); - let access_token = fake_jwt("workspace-1", "business"); - save_auth( - codex_home, - &AuthDotJson { - auth_mode: Some(AuthMode::Chatgpt), - openai_api_key: None, - tokens: Some(TokenData { - id_token: codex_core::token_data::parse_chatgpt_jwt_claims(&id_token) - .expect("id token should parse"), - access_token, - refresh_token: "refresh-token".to_string(), - account_id: Some("workspace-1".to_string()), - }), - last_refresh: Some(Utc::now()), - }, - AuthCredentialsStoreMode::File, - ) - .expect("chatgpt auth should save"); - } - - #[test] - fn refresh_request_uses_local_chatgpt_auth() { - let codex_home = TempDir::new().expect("tempdir"); - write_chatgpt_auth(codex_home.path()); - - let response = resolve_chatgpt_auth_tokens_refresh_response( - codex_home.path(), - AuthCredentialsStoreMode::File, - Some("workspace-1"), - &ChatgptAuthTokensRefreshParams { - reason: codex_app_server_protocol::ChatgptAuthTokensRefreshReason::Unauthorized, - previous_account_id: Some("workspace-1".to_string()), - }, - ) - .expect("refresh response should resolve"); - - assert_eq!(response.chatgpt_account_id, "workspace-1"); - assert_eq!(response.chatgpt_plan_type.as_deref(), Some("business")); - assert!(!response.access_token.is_empty()); - } - - #[test] - fn refresh_request_rejects_account_mismatch() { - let codex_home = TempDir::new().expect("tempdir"); - write_chatgpt_auth(codex_home.path()); - - let err = resolve_chatgpt_auth_tokens_refresh_response( - codex_home.path(), - AuthCredentialsStoreMode::File, - Some("workspace-1"), - &ChatgptAuthTokensRefreshParams { - reason: codex_app_server_protocol::ChatgptAuthTokensRefreshReason::Unauthorized, - previous_account_id: Some("workspace-2".to_string()), - }, - ) - .expect_err("mismatched account should fail"); - - assert_eq!( - err, - "local ChatGPT auth refresh account mismatch: expected `workspace-2`, got `workspace-1`" - ); - } -} - #[cfg(test)] fn app_server_web_search_action_to_core( action: codex_app_server_protocol::WebSearchAction, @@ -1286,9 +1004,7 @@ fn app_server_codex_error_info_to_core( #[cfg(test)] mod tests { - use super::LegacyThreadNotification; use super::command_execution_started_event; - use super::legacy_thread_notification; use super::server_notification_thread_events; use super::thread_snapshot_events; use super::turn_snapshot_events; @@ -1300,7 +1016,6 @@ mod tests { use codex_app_server_protocol::CommandExecutionStatus; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; - use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::Thread; @@ -1321,57 +1036,8 @@ mod tests { use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnAbortedEvent; use pretty_assertions::assert_eq; - use serde_json::json; use std::path::PathBuf; - #[test] - fn legacy_warning_notification_extracts_thread_id_and_message() { - let thread_id = ThreadId::new(); - let warning = legacy_thread_notification(JSONRPCNotification { - method: "codex/event/warning".to_string(), - params: Some(json!({ - "conversationId": thread_id.to_string(), - "id": "event-1", - "msg": { - "type": "warning", - "message": "legacy warning message", - }, - })), - }); - - assert_eq!( - warning, - Some(( - thread_id, - LegacyThreadNotification::Warning("legacy warning message".to_string()) - )) - ); - } - - #[test] - fn legacy_thread_rollback_notification_extracts_thread_id_and_turn_count() { - let thread_id = ThreadId::new(); - let rollback = legacy_thread_notification(JSONRPCNotification { - method: "codex/event/thread_rolled_back".to_string(), - params: Some(json!({ - "conversationId": thread_id.to_string(), - "id": "event-1", - "msg": { - "type": "thread_rolled_back", - "num_turns": 2, - }, - })), - }); - - assert_eq!( - rollback, - Some(( - thread_id, - LegacyThreadNotification::Rollback { num_turns: 2 } - )) - ); - } - #[test] fn bridges_completed_agent_messages_from_server_notifications() { let thread_id = "019cee8c-b993-7e33-88c0-014d4e62612d".to_string(); diff --git a/codex-rs/tui_app_server/src/app_server_session.rs b/codex-rs/tui_app_server/src/app_server_session.rs index c8a24acff4..0da85bc617 100644 --- a/codex-rs/tui_app_server/src/app_server_session.rs +++ b/codex-rs/tui_app_server/src/app_server_session.rs @@ -1,6 +1,7 @@ use codex_app_server_client::AppServerClient; use codex_app_server_client::AppServerEvent; use codex_app_server_client::AppServerRequestHandle; +use codex_app_server_client::TypedRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::ClientRequest; @@ -429,7 +430,7 @@ impl AppServerSession { thread_id: ThreadId, turn_id: String, items: Vec, - ) -> Result { + ) -> std::result::Result { let request_id = self.next_request_id(); self.client .request_typed(ClientRequest::TurnSteer { @@ -441,7 +442,6 @@ impl AppServerSession { }, }) .await - .wrap_err("turn/steer failed in app-server TUI") } pub(crate) async fn thread_set_name( diff --git a/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs b/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs index b86c029b5a..06d8396f8c 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs @@ -403,6 +403,7 @@ pub(crate) struct ChatComposer { config: ChatComposerConfig, collaboration_mode_indicator: Option, connectors_enabled: bool, + plugins_command_enabled: bool, fast_command_enabled: bool, personality_command_enabled: bool, realtime_conversation_enabled: bool, @@ -441,6 +442,7 @@ impl ChatComposer { BuiltinCommandFlags { collaboration_modes_enabled: self.collaboration_modes_enabled, connectors_enabled: self.connectors_enabled, + plugins_command_enabled: self.plugins_command_enabled, fast_command_enabled: self.fast_command_enabled, personality_command_enabled: self.personality_command_enabled, realtime_conversation_enabled: self.realtime_conversation_enabled, @@ -525,6 +527,7 @@ impl ChatComposer { config, collaboration_mode_indicator: None, connectors_enabled: false, + plugins_command_enabled: false, fast_command_enabled: false, personality_command_enabled: false, realtime_conversation_enabled: false, @@ -559,6 +562,10 @@ impl ChatComposer { self.sync_popups(); } + pub fn set_plugins_command_enabled(&mut self, enabled: bool) { + self.plugins_command_enabled = enabled; + } + /// Toggle composer-side image paste handling. /// /// This only affects whether image-like paste content is converted into attachments; the @@ -3491,6 +3498,7 @@ impl ChatComposer { if is_editing_slash_command_name { let collaboration_modes_enabled = self.collaboration_modes_enabled; let connectors_enabled = self.connectors_enabled; + let plugins_command_enabled = self.plugins_command_enabled; let fast_command_enabled = self.fast_command_enabled; let personality_command_enabled = self.personality_command_enabled; let realtime_conversation_enabled = self.realtime_conversation_enabled; @@ -3500,6 +3508,7 @@ impl ChatComposer { CommandPopupFlags { collaboration_modes_enabled, connectors_enabled, + plugins_command_enabled, fast_command_enabled, personality_command_enabled, realtime_conversation_enabled, @@ -3590,6 +3599,13 @@ impl ChatComposer { fn mention_items(&self) -> Vec { let mut mentions = Vec::new(); + let plugin_display_names: HashSet = + self.plugins.as_ref().map_or_else(HashSet::new, |plugins| { + plugins + .iter() + .map(|plugin| plugin.display_name.to_ascii_lowercase()) + .collect() + }); if let Some(skills) = self.skills.as_ref() { for skill in skills { @@ -3672,18 +3688,30 @@ impl ChatComposer { if !connector.is_accessible || !connector.is_enabled { continue; } + let plugin_backed_connector = connector + .plugin_display_names + .iter() + .any(|name| plugin_display_names.contains(&name.to_ascii_lowercase())); + if plugin_backed_connector { + continue; + } let display_name = connectors::connector_display_label(connector); let description = Some(Self::connector_brief_description(connector)); let slug = codex_core::connectors::connector_mention_slug(connector); let search_terms = vec![display_name.clone(), connector.id.clone(), slug.clone()]; let connector_id = connector.id.as_str(); + let category_tag = if connector.plugin_display_names.is_empty() { + "[App]".to_string() + } else { + "[Plugin]".to_string() + }; mentions.push(MentionItem { display_name: display_name.clone(), description, insert_text: format!("${slug}"), search_terms, path: Some(format!("app://{connector_id}")), - category_tag: Some("[App]".to_string()), + category_tag: Some(category_tag), sort_rank: 1, }); } @@ -5389,6 +5417,93 @@ mod tests { assert_eq!(mention.path, Some("plugin://sample@test".to_string())); } + #[test] + fn mention_items_keep_plugin_owned_skills_but_hide_duplicate_apps() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_connectors_enabled(true); + composer.set_text_content("$goog".to_string(), Vec::new(), Vec::new()); + composer.set_skill_mentions(Some(vec![SkillMetadata { + name: "google-calendar:availability".to_string(), + description: "Find availability and plan event changes".to_string(), + short_description: None, + interface: Some(codex_core::skills::model::SkillInterface { + display_name: Some("Google Calendar".to_string()), + short_description: None, + icon_small: None, + icon_large: None, + brand_color: None, + default_prompt: None, + }), + dependencies: None, + policy: None, + permission_profile: None, + managed_network_override: None, + path_to_skills_md: PathBuf::from("/tmp/repo/google-calendar/SKILL.md"), + scope: codex_protocol::protocol::SkillScope::Repo, + }])); + composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary { + config_name: "google-calendar@debug".to_string(), + display_name: "Google Calendar".to_string(), + description: Some( + "Connect Google Calendar for scheduling, availability, and event management." + .to_string(), + ), + has_skills: true, + mcp_server_names: vec!["google-calendar".to_string()], + app_connector_ids: vec![codex_core::plugins::AppConnectorId( + "google_calendar".to_string(), + )], + }])); + composer.set_connector_mentions(Some(ConnectorsSnapshot { + connectors: vec![AppInfo { + id: "google_calendar".to_string(), + name: "Google Calendar".to_string(), + description: Some("Look up events and availability".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://example.test/google-calendar".to_string()), + is_accessible: true, + is_enabled: true, + plugin_display_names: vec!["Google Calendar".to_string()], + }], + })); + + let mut mention_summaries: Vec<_> = composer + .mention_items() + .into_iter() + .map(|mention| (mention.display_name, mention.category_tag, mention.path)) + .collect(); + mention_summaries.sort(); + + assert_eq!( + mention_summaries, + vec![ + ( + "Google Calendar".to_string(), + Some("[Plugin]".to_string()), + Some("plugin://google-calendar@debug".to_string()), + ), + ( + "Google Calendar".to_string(), + Some("[Skill]".to_string()), + Some("/tmp/repo/google-calendar/SKILL.md".to_string()), + ), + ] + ); + } + #[test] fn plugin_mention_popup_snapshot() { snapshot_composer_state("plugin_mention_popup", false, |composer| { diff --git a/codex-rs/tui_app_server/src/bottom_pane/command_popup.rs b/codex-rs/tui_app_server/src/bottom_pane/command_popup.rs index 05b15b7935..36cc87668c 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/command_popup.rs @@ -38,6 +38,7 @@ pub(crate) struct CommandPopup { pub(crate) struct CommandPopupFlags { pub(crate) collaboration_modes_enabled: bool, pub(crate) connectors_enabled: bool, + pub(crate) plugins_command_enabled: bool, pub(crate) fast_command_enabled: bool, pub(crate) personality_command_enabled: bool, pub(crate) realtime_conversation_enabled: bool, @@ -50,6 +51,7 @@ impl From for slash_commands::BuiltinCommandFlags { Self { collaboration_modes_enabled: value.collaboration_modes_enabled, connectors_enabled: value.connectors_enabled, + plugins_command_enabled: value.plugins_command_enabled, fast_command_enabled: value.fast_command_enabled, personality_command_enabled: value.personality_command_enabled, realtime_conversation_enabled: value.realtime_conversation_enabled, @@ -510,6 +512,7 @@ mod tests { CommandPopupFlags { collaboration_modes_enabled: true, connectors_enabled: false, + plugins_command_enabled: false, fast_command_enabled: false, personality_command_enabled: true, realtime_conversation_enabled: false, @@ -532,6 +535,7 @@ mod tests { CommandPopupFlags { collaboration_modes_enabled: true, connectors_enabled: false, + plugins_command_enabled: false, fast_command_enabled: false, personality_command_enabled: true, realtime_conversation_enabled: false, @@ -554,6 +558,7 @@ mod tests { CommandPopupFlags { collaboration_modes_enabled: true, connectors_enabled: false, + plugins_command_enabled: false, fast_command_enabled: false, personality_command_enabled: false, realtime_conversation_enabled: false, @@ -584,6 +589,7 @@ mod tests { CommandPopupFlags { collaboration_modes_enabled: true, connectors_enabled: false, + plugins_command_enabled: false, fast_command_enabled: false, personality_command_enabled: true, realtime_conversation_enabled: false, @@ -606,6 +612,7 @@ mod tests { CommandPopupFlags { collaboration_modes_enabled: false, connectors_enabled: false, + plugins_command_enabled: false, fast_command_enabled: false, personality_command_enabled: true, realtime_conversation_enabled: true, diff --git a/codex-rs/tui_app_server/src/bottom_pane/file_search_popup.rs b/codex-rs/tui_app_server/src/bottom_pane/file_search_popup.rs index c1c5296648..fe903a0b37 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/file_search_popup.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/file_search_popup.rs @@ -70,7 +70,7 @@ impl FileSearchPopup { } self.display_query = query.to_string(); - self.matches = matches; + self.matches = matches.into_iter().take(MAX_POPUP_ROWS).collect(); self.waiting = false; let len = self.matches.len(); self.state.clamp_selection(len); @@ -152,3 +152,33 @@ impl WidgetRef for &FileSearchPopup { ); } } + +#[cfg(test)] +mod tests { + use super::*; + use codex_file_search::MatchType; + use pretty_assertions::assert_eq; + + fn file_match(index: usize) -> FileMatch { + FileMatch { + score: index as u32, + path: PathBuf::from(format!("src/file_{index:02}.rs")), + match_type: MatchType::File, + root: PathBuf::from("/tmp/repo"), + indices: None, + } + } + + #[test] + fn set_matches_keeps_only_the_first_page_of_results() { + let mut popup = FileSearchPopup::new(); + popup.set_query("file"); + popup.set_matches("file", (0..(MAX_POPUP_ROWS + 2)).map(file_match).collect()); + + assert_eq!( + popup.matches, + (0..MAX_POPUP_ROWS).map(file_match).collect::>() + ); + assert_eq!(popup.calculate_required_height(), MAX_POPUP_ROWS as u16); + } +} diff --git a/codex-rs/tui_app_server/src/bottom_pane/mod.rs b/codex-rs/tui_app_server/src/bottom_pane/mod.rs index c7d63be402..dd90bc11bd 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/mod.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/mod.rs @@ -262,6 +262,11 @@ impl BottomPane { self.request_redraw(); } + pub fn set_plugins_command_enabled(&mut self, enabled: bool) { + self.composer.set_plugins_command_enabled(enabled); + self.request_redraw(); + } + pub fn take_mention_bindings(&mut self) -> Vec { self.composer.take_mention_bindings() } @@ -816,8 +821,10 @@ impl BottomPane { &mut self, queued: Vec, pending_steers: Vec, + rejected_steers: Vec, ) { self.pending_input_preview.pending_steers = pending_steers; + self.pending_input_preview.rejected_steers = rejected_steers; self.pending_input_preview.queued_messages = queued; self.request_redraw(); } @@ -1138,7 +1145,8 @@ impl BottomPane { } let has_pending_thread_approvals = !self.pending_thread_approvals.is_empty(); let has_pending_input = !self.pending_input_preview.queued_messages.is_empty() - || !self.pending_input_preview.pending_steers.is_empty(); + || !self.pending_input_preview.pending_steers.is_empty() + || !self.pending_input_preview.rejected_steers.is_empty(); let has_status_or_footer = self.status.is_some() || !self.unified_exec_footer.is_empty(); let has_inline_previews = has_pending_thread_approvals || has_pending_input; @@ -1541,7 +1549,11 @@ mod tests { StatusDetailsCapitalization::CapitalizeFirst, STATUS_DETAILS_DEFAULT_MAX_LINES, ); - pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new()); + pane.set_pending_input_preview( + vec!["Queued follow-up question".to_string()], + Vec::new(), + Vec::new(), + ); let width = 48; let height = pane.desired_height(width); @@ -1568,7 +1580,11 @@ mod tests { }); pane.set_task_running(true); - pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new()); + pane.set_pending_input_preview( + vec!["Queued follow-up question".to_string()], + Vec::new(), + Vec::new(), + ); pane.hide_status_indicator(); let width = 48; @@ -1596,7 +1612,11 @@ mod tests { }); pane.set_task_running(true); - pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new()); + pane.set_pending_input_preview( + vec!["Queued follow-up question".to_string()], + Vec::new(), + Vec::new(), + ); let width = 48; let height = pane.desired_height(width); diff --git a/codex-rs/tui_app_server/src/bottom_pane/pending_input_preview.rs b/codex-rs/tui_app_server/src/bottom_pane/pending_input_preview.rs index 315e311e02..b9690c28ee 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/pending_input_preview.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/pending_input_preview.rs @@ -10,17 +10,19 @@ use crate::render::renderable::Renderable; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_lines; -/// Widget that displays pending steers plus user messages queued while a turn is in progress. +/// Widget that displays pending steers plus follow-up messages held while a turn is in progress. /// -/// The widget renders pending steers first, then queued user messages, as two -/// labeled sections. Pending steers explain that they will be submitted after -/// the next tool/result boundary unless the user presses Esc to interrupt and -/// send them immediately. The edit hint at the bottom only appears when there -/// are actual queued user messages to pop back into the composer. Because some -/// terminals intercept certain modifier-key combinations, the displayed -/// binding is configurable via [`set_edit_binding`](Self::set_edit_binding). +/// The widget renders pending steers first, then rejected steers that will be +/// resubmitted at end of turn, then ordinary queued user messages. Pending +/// steers explain that they will be submitted after the next tool/result +/// boundary unless the user presses Esc to interrupt and send them +/// immediately. The edit hint at the bottom only appears when there are actual +/// queued user messages to pop back into the composer. Because some terminals +/// intercept certain modifier-key combinations, the displayed binding is +/// configurable via [`set_edit_binding`](Self::set_edit_binding). pub(crate) struct PendingInputPreview { pub pending_steers: Vec, + pub rejected_steers: Vec, pub queued_messages: Vec, /// Key combination rendered in the hint line. Defaults to Alt+Up but may /// be overridden for terminals where that chord is unavailable. @@ -33,6 +35,7 @@ impl PendingInputPreview { pub(crate) fn new() -> Self { Self { pending_steers: Vec::new(), + rejected_steers: Vec::new(), queued_messages: Vec::new(), edit_binding: key_hint::alt(KeyCode::Up), } @@ -67,7 +70,11 @@ impl PendingInputPreview { } fn as_renderable(&self, width: u16) -> Box { - if (self.pending_steers.is_empty() && self.queued_messages.is_empty()) || width < 4 { + if (self.pending_steers.is_empty() + && self.rejected_steers.is_empty() + && self.queued_messages.is_empty()) + || width < 4 + { return Box::new(()); } @@ -96,6 +103,27 @@ impl PendingInputPreview { } } + if !self.rejected_steers.is_empty() { + if !lines.is_empty() { + lines.push(Line::from("")); + } + Self::push_section_header( + &mut lines, + width, + "Messages to be submitted at end of turn".into(), + ); + + for steer in &self.rejected_steers { + let wrapped = adaptive_wrap_lines( + steer.lines().map(|line| Line::from(line.dim())), + RtOptions::new(width as usize) + .initial_indent(Line::from(" ↳ ".dim())) + .subsequent_indent(Line::from(" ")), + ); + Self::push_truncated_preview_lines(&mut lines, wrapped, Line::from(" …".dim())); + } + } + if !self.queued_messages.is_empty() { if !lines.is_empty() { lines.push(Line::from("")); @@ -176,6 +204,21 @@ mod tests { assert_snapshot!("render_one_message", format!("{buf:?}")); } + #[test] + fn render_one_message_with_shift_left_binding() { + let mut queue = PendingInputPreview::new(); + queue.queued_messages.push("Hello, world!".to_string()); + queue.set_edit_binding(key_hint::shift(KeyCode::Left)); + let width = 40; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!( + "render_one_message_with_shift_left_binding", + format!("{buf:?}") + ); + } + #[test] fn render_two_messages() { let mut queue = PendingInputPreview::new(); @@ -289,6 +332,9 @@ mod tests { queue .pending_steers .push("Check the last command output.".to_string()); + queue + .rejected_steers + .push("Rejected steer that will be retried.".to_string()); queue .queued_messages .push("Queued follow-up question".to_string()); diff --git a/codex-rs/tui_app_server/src/bottom_pane/skill_popup.rs b/codex-rs/tui_app_server/src/bottom_pane/skill_popup.rs index 841ce23b8b..061d3f3b82 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/skill_popup.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/skill_popup.rs @@ -180,6 +180,7 @@ impl SkillPopup { }) }); + out.truncate(MAX_POPUP_ROWS); out } } @@ -229,3 +230,43 @@ fn skill_popup_hint_line() -> Line<'static> { " to close".into(), ]) } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn mention_item(index: usize) -> MentionItem { + MentionItem { + display_name: format!("Mention {index:02}"), + description: Some(format!("Description {index:02}")), + insert_text: format!("$mention-{index:02}"), + search_terms: vec![format!("mention-{index:02}")], + path: Some(format!("skill://mention-{index:02}")), + category_tag: Some("[Skill]".to_string()), + sort_rank: 1, + } + } + + #[test] + fn filtered_mentions_are_capped_to_max_popup_rows() { + let popup = SkillPopup::new((0..(MAX_POPUP_ROWS + 2)).map(mention_item).collect()); + + let filtered_names: Vec = popup + .filtered_items() + .into_iter() + .map(|idx| popup.mentions[idx].display_name.clone()) + .collect(); + + assert_eq!( + filtered_names, + (0..MAX_POPUP_ROWS) + .map(|idx| format!("Mention {idx:02}")) + .collect::>() + ); + assert_eq!( + popup.calculate_required_height(72), + (MAX_POPUP_ROWS as u16) + 2 + ); + } +} diff --git a/codex-rs/tui_app_server/src/bottom_pane/slash_commands.rs b/codex-rs/tui_app_server/src/bottom_pane/slash_commands.rs index 15b70f232c..54b1a8cf4e 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/slash_commands.rs @@ -14,6 +14,7 @@ use crate::slash_command::built_in_slash_commands; pub(crate) struct BuiltinCommandFlags { pub(crate) collaboration_modes_enabled: bool, pub(crate) connectors_enabled: bool, + pub(crate) plugins_command_enabled: bool, pub(crate) fast_command_enabled: bool, pub(crate) personality_command_enabled: bool, pub(crate) realtime_conversation_enabled: bool, @@ -31,6 +32,7 @@ pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static st || !matches!(*cmd, SlashCommand::Collab | SlashCommand::Plan) }) .filter(|(_, cmd)| flags.connectors_enabled || *cmd != SlashCommand::Apps) + .filter(|(_, cmd)| flags.plugins_command_enabled || *cmd != SlashCommand::Plugins) .filter(|(_, cmd)| flags.fast_command_enabled || *cmd != SlashCommand::Fast) .filter(|(_, cmd)| flags.personality_command_enabled || *cmd != SlashCommand::Personality) .filter(|(_, cmd)| flags.realtime_conversation_enabled || *cmd != SlashCommand::Realtime) @@ -63,6 +65,7 @@ mod tests { BuiltinCommandFlags { collaboration_modes_enabled: true, connectors_enabled: true, + plugins_command_enabled: true, fast_command_enabled: true, personality_command_enabled: true, realtime_conversation_enabled: true, diff --git a/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__pending_input_preview__tests__render_one_message_with_shift_left_binding.snap b/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__pending_input_preview__tests__render_one_message_with_shift_left_binding.snap new file mode 100644 index 0000000000..d70db56644 --- /dev/null +++ b/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__pending_input_preview__tests__render_one_message_with_shift_left_binding.snap @@ -0,0 +1,21 @@ +--- +source: tui_app_server/src/bottom_pane/pending_input_preview.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 3 }, + content: [ + "• Queued follow-up messages ", + " ↳ Hello, world! ", + " shift + ← edit last queued message ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 17, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 38, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap b/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap index 0f0d1eabd3..16da87fb91 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap +++ b/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap @@ -3,13 +3,16 @@ source: tui_app_server/src/bottom_pane/pending_input_preview.rs expression: "format!(\"{buf:?}\")" --- Buffer { - area: Rect { x: 0, y: 0, width: 52, height: 8 }, + area: Rect { x: 0, y: 0, width: 52, height: 11 }, content: [ "• Messages to be submitted after next tool call ", " (press esc to interrupt and send immediately) ", " ↳ Please continue. ", " ↳ Check the last command output. ", " ", + "• Messages to be submitted at end of turn ", + " ↳ Rejected steer that will be retried. ", + " ", "• Queued follow-up messages ", " ↳ Queued follow-up question ", " ⌥ + ↑ edit last queued message ", @@ -26,9 +29,13 @@ Buffer { x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 29, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 34, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 40, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 2, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 29, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 34, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, ] } diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index 5e0cff03c7..ad8b9e709e 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -199,6 +199,8 @@ use codex_protocol::request_user_input::RequestUserInputEvent; use codex_protocol::request_user_input::RequestUserInputQuestionOption; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; +use codex_terminal_detection::Multiplexer; +use codex_terminal_detection::TerminalInfo; use codex_terminal_detection::TerminalName; use codex_terminal_detection::terminal_info; use codex_utils_sleep_inhibitor::SleepInhibitor; @@ -238,14 +240,21 @@ const APP_SERVER_TUI_STUB_MESSAGE: &str = "Not available in app-server TUI yet." /// Choose the keybinding used to edit the most-recently queued message. /// /// Apple Terminal, Warp, and VSCode integrated terminals intercept or silently -/// swallow Alt+Up, so users in those environments would never be able to trigger -/// the edit action. We fall back to Shift+Left for those terminals while -/// keeping the more discoverable Alt+Up everywhere else. +/// swallow Alt+Up, and tmux does not reliably pass that chord through. We fall +/// back to Shift+Left for those environments while keeping the more discoverable +/// Alt+Up everywhere else. /// /// The match is exhaustive so that adding a new `TerminalName` variant forces /// an explicit decision about which binding that terminal should use. -fn queued_message_edit_binding_for_terminal(terminal_name: TerminalName) -> KeyBinding { - match terminal_name { +fn queued_message_edit_binding_for_terminal(terminal_info: TerminalInfo) -> KeyBinding { + if matches!( + terminal_info.multiplexer.as_ref(), + Some(Multiplexer::Tmux { .. }) + ) { + return key_hint::shift(KeyCode::Left); + } + + match terminal_info.name { TerminalName::AppleTerminal | TerminalName::WarpTerminal | TerminalName::VsCode => { key_hint::shift(KeyCode::Left) } @@ -798,6 +807,8 @@ pub(crate) struct ChatWidget { suppress_initial_user_message_submit: bool, // User messages queued while a turn is in progress queued_user_messages: VecDeque, + // User messages that tried to steer a non-regular turn and must be retried first. + rejected_steers_queue: VecDeque, // Steers already submitted to core but not yet committed into history. // // The bottom pane shows these above queued drafts until core records the @@ -949,9 +960,11 @@ impl ThreadComposerState { pub(crate) struct ThreadInputState { composer: Option, pending_steers: VecDeque, + rejected_steers_queue: VecDeque, queued_user_messages: VecDeque, current_collaboration_mode: CollaborationMode, active_collaboration_mask: Option, + task_running: bool, agent_turn_running: bool, } @@ -1791,6 +1804,7 @@ impl ChatWidget { self.refresh_model_display(); self.sync_fast_command_enabled(); self.sync_personality_command_enabled(); + self.sync_plugins_command_enabled(); self.refresh_plugin_mentions(); let startup_tooltip_override = self.startup_tooltip_override.take(); let show_fast_status = self.should_show_fast_status(&model_for_header, event.service_tier); @@ -2162,7 +2176,7 @@ impl ChatWidget { let had_pending_steers = !self.pending_steers.is_empty(); self.refresh_pending_input_preview(); - if !from_replay && self.queued_user_messages.is_empty() && !had_pending_steers { + if !from_replay && !self.has_queued_follow_up_messages() && !had_pending_steers { self.maybe_prompt_plan_implementation(); } // Keep this flag for replayed completion events so a subsequent live TurnComplete can @@ -2184,7 +2198,7 @@ impl ChatWidget { if !self.collaboration_modes_enabled() { return; } - if !self.queued_user_messages.is_empty() { + if self.has_queued_follow_up_messages() { return; } if self.active_mode_kind() != ModeKind::Plan { @@ -2256,6 +2270,57 @@ impl ChatWidget { }); } + fn has_queued_follow_up_messages(&self) -> bool { + !self.rejected_steers_queue.is_empty() || !self.queued_user_messages.is_empty() + } + + fn pop_next_queued_user_message(&mut self) -> Option { + if self.rejected_steers_queue.is_empty() { + self.queued_user_messages.pop_front() + } else { + Some(merge_user_messages( + self.rejected_steers_queue.drain(..).collect(), + )) + } + } + + fn pop_latest_queued_user_message(&mut self) -> Option { + self.queued_user_messages + .pop_back() + .or_else(|| self.rejected_steers_queue.pop_back()) + } + + pub(crate) fn enqueue_rejected_steer(&mut self) -> bool { + let Some(pending_steer) = self.pending_steers.pop_front() else { + tracing::warn!( + "received active-turn-not-steerable error without a matching pending steer" + ); + return false; + }; + self.rejected_steers_queue + .push_back(pending_steer.user_message); + self.refresh_pending_input_preview(); + true + } + + #[cfg(test)] + fn handle_steer_rejected_error(&mut self, codex_error_info: &CoreCodexErrorInfo) -> bool { + matches!( + codex_error_info, + CoreCodexErrorInfo::ActiveTurnNotSteerable { .. } + ) && self.enqueue_rejected_steer() + } + + fn handle_app_server_steer_rejected_error( + &mut self, + codex_error_info: &AppServerCodexErrorInfo, + ) -> bool { + matches!( + codex_error_info, + AppServerCodexErrorInfo::ActiveTurnNotSteerable { .. } + ) && self.enqueue_rejected_steer() + } + pub(crate) fn open_multi_agent_enable_prompt(&mut self) { let items = vec![ SelectionItem { @@ -2498,7 +2563,11 @@ impl ChatWidget { message: String, codex_error_info: Option, ) { - if let Some(info) = codex_error_info + if codex_error_info + .as_ref() + .is_some_and(|info| self.handle_app_server_steer_rejected_error(info)) + { + } else if let Some(info) = codex_error_info .as_ref() .and_then(app_server_rate_limit_error_kind) { @@ -2638,7 +2707,7 @@ impl ChatWidget { /// state stays aligned with the merged attachment list. Returns `None` when there is nothing to /// restore. fn drain_pending_messages_for_restore(&mut self) -> Option { - if self.pending_steers.is_empty() && self.queued_user_messages.is_empty() { + if self.pending_steers.is_empty() && !self.has_queued_follow_up_messages() { return None; } @@ -2650,11 +2719,12 @@ impl ChatWidget { mention_bindings: self.bottom_pane.composer_mention_bindings(), }; - let mut to_merge: Vec = self - .pending_steers - .drain(..) - .map(|steer| steer.user_message) - .collect(); + let mut to_merge: Vec = self.rejected_steers_queue.drain(..).collect(); + to_merge.extend( + self.pending_steers + .drain(..) + .map(|steer| steer.user_message), + ); to_merge.extend(self.queued_user_messages.drain(..)); if !existing_message.text.is_empty() || !existing_message.local_images.is_empty() @@ -2700,14 +2770,17 @@ impl ChatWidget { .iter() .map(|pending| pending.user_message.clone()) .collect(), + rejected_steers_queue: self.rejected_steers_queue.clone(), queued_user_messages: self.queued_user_messages.clone(), current_collaboration_mode: self.current_collaboration_mode.clone(), active_collaboration_mask: self.active_collaboration_mask.clone(), + task_running: self.bottom_pane.is_task_running(), agent_turn_running: self.agent_turn_running, }) } pub(crate) fn restore_thread_input_state(&mut self, input_state: Option) { + let restored_task_running = input_state.as_ref().is_some_and(|state| state.task_running); if let Some(input_state) = input_state { self.current_collaboration_mode = input_state.current_collaboration_mode; self.active_collaboration_mask = input_state.active_collaboration_mask; @@ -2739,13 +2812,24 @@ impl ChatWidget { ); self.bottom_pane.set_composer_pending_pastes(Vec::new()); } - self.pending_steers.clear(); - self.queued_user_messages = input_state.pending_steers; - self.queued_user_messages - .extend(input_state.queued_user_messages); + self.pending_steers = input_state + .pending_steers + .into_iter() + .map(|user_message| PendingSteer { + compare_key: PendingSteerCompareKey { + message: user_message.text.clone(), + image_count: user_message.local_images.len() + + user_message.remote_image_urls.len(), + }, + user_message, + }) + .collect(); + self.rejected_steers_queue = input_state.rejected_steers_queue; + self.queued_user_messages = input_state.queued_user_messages; } else { self.agent_turn_running = false; self.pending_steers.clear(); + self.rejected_steers_queue.clear(); self.set_remote_image_urls(Vec::new()); self.bottom_pane.set_composer_text_with_mention_bindings( String::new(), @@ -2759,6 +2843,9 @@ impl ChatWidget { self.turn_sleep_inhibitor .set_turn_running(self.agent_turn_running); self.update_task_running_state(); + if restored_task_running && !self.bottom_pane.is_task_running() { + self.bottom_pane.set_task_running(/*running*/ true); + } self.refresh_pending_input_preview(); self.request_redraw(); } @@ -4169,8 +4256,7 @@ impl ChatWidget { let active_cell = Some(Self::placeholder_session_header_cell(&config)); let current_cwd = Some(config.cwd.clone()); - let queued_message_edit_binding = - queued_message_edit_binding_for_terminal(terminal_info().name); + let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info()); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -4235,6 +4321,7 @@ impl ChatWidget { thread_name: None, forked_from: None, queued_user_messages: VecDeque::new(), + rejected_steers_queue: VecDeque::new(), pending_steers: VecDeque::new(), submit_pending_steers_after_interrupt: false, queued_message_edit_binding, @@ -4290,6 +4377,7 @@ impl ChatWidget { .set_collaboration_modes_enabled(/*enabled*/ true); widget.sync_fast_command_enabled(); widget.sync_personality_command_enabled(); + widget.sync_plugins_command_enabled(); widget .bottom_pane .set_queued_message_edit_binding(widget.queued_message_edit_binding); @@ -4371,9 +4459,9 @@ impl ChatWidget { if key_event.kind == KeyEventKind::Press && self.queued_message_edit_binding.is_press(key_event) - && !self.queued_user_messages.is_empty() + && self.has_queued_follow_up_messages() { - if let Some(user_message) = self.queued_user_messages.pop_back() { + if let Some(user_message) = self.pop_latest_queued_user_message() { self.restore_user_message_to_composer(user_message); self.refresh_pending_input_preview(); self.request_redraw(); @@ -4597,6 +4685,9 @@ impl ChatWidget { } SlashCommand::Compact => { self.clear_token_usage(); + if !self.bottom_pane.is_task_running() { + self.bottom_pane.set_task_running(/*running*/ true); + } self.app_event_tx.compact(); } SlashCommand::Review => { @@ -5089,10 +5180,7 @@ impl ChatWidget { } fn queue_user_message(&mut self, user_message: UserMessage) { - if !self.is_session_configured() - || self.bottom_pane.is_task_running() - || self.is_review_mode - { + if !self.is_session_configured() || self.bottom_pane.is_task_running() { self.queued_user_messages.push_back(user_message); self.refresh_pending_input_preview(); } else { @@ -5107,12 +5195,6 @@ impl ChatWidget { self.refresh_pending_input_preview(); return; } - if self.is_review_mode { - self.queued_user_messages.push_back(user_message); - self.refresh_pending_input_preview(); - return; - } - let UserMessage { text, local_images, @@ -6069,6 +6151,7 @@ impl ChatWidget { | ServerNotification::ContextCompacted(_) | ServerNotification::FuzzyFileSearchSessionUpdated(_) | ServerNotification::FuzzyFileSearchSessionCompleted(_) + | ServerNotification::ThreadRealtimeTranscriptUpdated(_) | ServerNotification::WindowsWorldWritableWarning(_) | ServerNotification::WindowsSandboxSetupCompleted(_) | ServerNotification::AccountLoginCompleted(_) => {} @@ -6395,7 +6478,9 @@ impl ChatWidget { } EventMsg::TurnComplete(TurnCompleteEvent { last_agent_message, .. - }) => self.on_task_complete(last_agent_message, from_replay), + }) => { + self.on_task_complete(last_agent_message, from_replay); + } EventMsg::TokenCount(ev) => { self.set_token_info(ev.info); self.on_rate_limit_snapshot(ev.rate_limits); @@ -6407,8 +6492,13 @@ impl ChatWidget { message, codex_error_info, }) => { - if let Some(info) = codex_error_info - && let Some(kind) = core_rate_limit_error_kind(&info) + if codex_error_info + .as_ref() + .is_some_and(|info| self.handle_steer_rejected_error(info)) + { + } else if let Some(kind) = codex_error_info + .as_ref() + .and_then(core_rate_limit_error_kind) { match kind { RateLimitErrorKind::ServerOverloaded => { @@ -6773,7 +6863,7 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } - if let Some(user_message) = self.queued_user_messages.pop_front() { + if let Some(user_message) = self.pop_next_queued_user_message() { self.submit_user_message(user_message); } // Update the list to reflect the remaining queued messages (if any). @@ -6792,8 +6882,16 @@ impl ChatWidget { .iter() .map(|steer| steer.user_message.text.clone()) .collect(); - self.bottom_pane - .set_pending_input_preview(queued_messages, pending_steers); + let rejected_steers: Vec = self + .rejected_steers_queue + .iter() + .map(|message| message.text.clone()) + .collect(); + self.bottom_pane.set_pending_input_preview( + queued_messages, + pending_steers, + rejected_steers, + ); } pub(crate) fn set_pending_thread_approvals(&mut self, threads: Vec) { @@ -9002,6 +9100,7 @@ impl ChatWidget { self.sync_personality_command_enabled(); } if feature == Feature::Plugins { + self.sync_plugins_command_enabled(); self.refresh_plugin_mentions(); } if feature == Feature::PreventIdleSleep { @@ -9232,6 +9331,11 @@ impl ChatWidget { .set_personality_command_enabled(self.config.features.enabled(Feature::Personality)); } + fn sync_plugins_command_enabled(&mut self) { + self.bottom_pane + .set_plugins_command_enabled(self.config.features.enabled(Feature::Plugins)); + } + fn current_model_supports_personality(&self) -> bool { let model = self.current_model(); self.model_catalog @@ -9539,10 +9643,6 @@ impl ChatWidget { self.request_redraw(); } - pub(crate) fn add_warning_message(&mut self, message: String) { - self.on_warning(message); - } - fn add_app_server_stub_message(&mut self, feature: &str) { warn!(feature, "stubbed unsupported app-server TUI feature"); self.add_error_message(format!("{feature}: {APP_SERVER_TUI_STUB_MESSAGE}")); @@ -10022,9 +10122,14 @@ impl ChatWidget { #[cfg(test)] pub(crate) fn queued_user_message_texts(&self) -> Vec { - self.queued_user_messages + self.rejected_steers_queue .iter() .map(|message| message.text.clone()) + .chain( + self.queued_user_messages + .iter() + .map(|message| message.text.clone()), + ) .collect() } diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__compact_queues_user_messages_snapshot.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__compact_queues_user_messages_snapshot.snap new file mode 100644 index 0000000000..65f7845183 --- /dev/null +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__compact_queues_user_messages_snapshot.snap @@ -0,0 +1,21 @@ +--- +source: tui_app_server/src/chatwidget/tests.rs +expression: term.backend().vt100().screen().contents() +--- + + + + + + + + + +• Working (0s • esc to interrupt) + +• Messages to be submitted at end of turn + ↳ Steer submitted while /compact was running. + +› Ask Codex to do anything + + ? for shortcuts 100% context left diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__review_queues_user_messages_snapshot.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__review_queues_user_messages_snapshot.snap index 3985a1dc2c..ad7a7ed279 100644 --- a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__review_queues_user_messages_snapshot.snap +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__review_queues_user_messages_snapshot.snap @@ -11,11 +11,11 @@ expression: term.backend().vt100().screen().contents() + • Working (0s • esc to interrupt) -• Queued follow-up messages - ↳ Queued while /review is running. - ⌥ + ↑ edit last queued message +• Messages to be submitted at end of turn + ↳ Steer submitted while /review was running. › Ask Codex to do anything diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index 639b57da09..e8173a43ff 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -112,6 +112,7 @@ use codex_protocol::protocol::ItemCompletedEvent; use codex_protocol::protocol::McpStartupCompleteEvent; use codex_protocol::protocol::McpStartupStatus; use codex_protocol::protocol::McpStartupUpdateEvent; +use codex_protocol::protocol::NonSteerableTurnKind; use codex_protocol::protocol::Op; use codex_protocol::protocol::PatchApplyBeginEvent; use codex_protocol::protocol::PatchApplyEndEvent; @@ -143,6 +144,8 @@ use codex_protocol::request_user_input::RequestUserInputQuestion; use codex_protocol::request_user_input::RequestUserInputQuestionOption; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; +use codex_terminal_detection::Multiplexer; +use codex_terminal_detection::TerminalInfo; use codex_terminal_detection::TerminalName; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_approval_presets::builtin_approval_presets; @@ -1558,6 +1561,131 @@ async fn entered_review_mode_defaults_to_current_changes_banner() { assert!(chat.is_review_mode); } +#[tokio::test] +async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + chat.handle_codex_event(Event { + id: "turn-start".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); + chat.handle_codex_event(Event { + id: "review-start".into(), + msg: EventMsg::EnteredReviewMode(ReviewRequest { + target: ReviewTarget::BaseBranch { + branch: "feature".to_string(), + }, + user_facing_hint: Some("feature branch".to_string()), + }), + }); + let _ = drain_insert_history(&mut rx); + chat.queued_user_messages + .push_back(UserMessage::from("queued later")); + + chat.submit_user_message(UserMessage::from("review follow-up one")); + chat.submit_user_message(UserMessage::from("review follow-up two")); + + assert_eq!(chat.pending_steers.len(), 2); + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "review follow-up one".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected running-turn steer submit, got {other:?}"), + } + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "review follow-up two".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected second running-turn steer submit, got {other:?}"), + } + + chat.handle_codex_event(Event { + id: "steer-rejected-1".into(), + msg: EventMsg::Error(ErrorEvent { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + }), + }); + chat.handle_codex_event(Event { + id: "steer-rejected-2".into(), + msg: EventMsg::Error(ErrorEvent { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + }), + }); + + assert!(chat.pending_steers.is_empty()); + assert_eq!( + chat.queued_user_message_texts(), + vec![ + "review follow-up one", + "review follow-up two", + "queued later" + ] + ); + assert!(drain_insert_history(&mut rx).is_empty()); + + chat.handle_codex_event(Event { + id: "review-exit".into(), + msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent { + review_output: None, + }), + }); + chat.handle_codex_event(Event { + id: "turn-complete".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: None, + }), + }); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "review follow-up one\nreview follow-up two".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected merged rejected-steer follow-up submit, got {other:?}"), + } + + chat.handle_codex_event(Event { + id: "turn-complete-2".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-2".to_string(), + last_agent_message: None, + }), + }); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "queued later".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected queued draft submit after rejected steers, got {other:?}"), + } +} + #[tokio::test] async fn live_agent_message_renders_during_review_mode() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; @@ -1932,6 +2060,7 @@ async fn make_chatwidget_manual( show_welcome_banner: true, startup_tooltip_override: None, queued_user_messages: VecDeque::new(), + rejected_steers_queue: VecDeque::new(), pending_steers: VecDeque::new(), submit_pending_steers_after_interrupt: false, queued_message_edit_binding: crate::key_hint::alt(KeyCode::Up), @@ -3714,9 +3843,11 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() { chat.restore_thread_input_state(Some(ThreadInputState { composer: None, pending_steers: VecDeque::new(), + rejected_steers_queue: VecDeque::new(), queued_user_messages: VecDeque::new(), current_collaboration_mode: chat.current_collaboration_mode.clone(), active_collaboration_mask: chat.active_collaboration_mask.clone(), + task_running: true, agent_turn_running: true, })); @@ -3731,6 +3862,38 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() { assert!(!chat.bottom_pane.is_task_running()); } +#[tokio::test] +async fn restore_thread_input_state_restores_pending_steers_without_downgrading_them() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let mut pending_steers = VecDeque::new(); + pending_steers.push_back(UserMessage::from("pending steer")); + let mut rejected_steers_queue = VecDeque::new(); + rejected_steers_queue.push_back(UserMessage::from("already rejected")); + let mut queued_user_messages = VecDeque::new(); + queued_user_messages.push_back(UserMessage::from("queued draft")); + + chat.restore_thread_input_state(Some(ThreadInputState { + composer: None, + pending_steers, + rejected_steers_queue, + queued_user_messages, + current_collaboration_mode: chat.current_collaboration_mode.clone(), + active_collaboration_mask: chat.active_collaboration_mask.clone(), + task_running: false, + agent_turn_running: false, + })); + + assert_eq!( + chat.queued_user_message_texts(), + vec!["already rejected", "queued draft"] + ); + assert_eq!(chat.pending_steers.len(), 1); + assert_eq!( + chat.pending_steers.front().unwrap().user_message.text, + "pending steer" + ); +} + #[tokio::test] async fn alt_up_edits_most_recent_queued_message() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -3765,10 +3928,10 @@ async fn alt_up_edits_most_recent_queued_message() { } async fn assert_shift_left_edits_most_recent_queued_message_for_terminal( - terminal_name: TerminalName, + terminal_info: TerminalInfo, ) { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - chat.queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_name); + chat.queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info); chat.bottom_pane .set_queued_message_edit_binding(chat.queued_message_edit_binding); @@ -3800,37 +3963,102 @@ async fn assert_shift_left_edits_most_recent_queued_message_for_terminal( #[tokio::test] async fn shift_left_edits_most_recent_queued_message_in_apple_terminal() { - assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalName::AppleTerminal) - .await; + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { + name: TerminalName::AppleTerminal, + term_program: None, + version: None, + term: None, + multiplexer: None, + }) + .await; } #[tokio::test] async fn shift_left_edits_most_recent_queued_message_in_warp_terminal() { - assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalName::WarpTerminal) - .await; + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { + name: TerminalName::WarpTerminal, + term_program: None, + version: None, + term: None, + multiplexer: None, + }) + .await; } #[tokio::test] async fn shift_left_edits_most_recent_queued_message_in_vscode_terminal() { - assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalName::VsCode).await; + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { + name: TerminalName::VsCode, + term_program: None, + version: None, + term: None, + multiplexer: None, + }) + .await; +} + +#[tokio::test] +async fn shift_left_edits_most_recent_queued_message_in_tmux() { + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { + name: TerminalName::Iterm2, + term_program: None, + version: None, + term: None, + multiplexer: Some(Multiplexer::Tmux { version: None }), + }) + .await; } #[test] -fn queued_message_edit_binding_mapping_covers_special_terminals() { +fn queued_message_edit_binding_mapping_covers_special_terminals_and_tmux() { assert_eq!( - queued_message_edit_binding_for_terminal(TerminalName::AppleTerminal), + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::AppleTerminal, + term_program: None, + version: None, + term: None, + multiplexer: None, + }), crate::key_hint::shift(KeyCode::Left) ); assert_eq!( - queued_message_edit_binding_for_terminal(TerminalName::WarpTerminal), + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::WarpTerminal, + term_program: None, + version: None, + term: None, + multiplexer: None, + }), crate::key_hint::shift(KeyCode::Left) ); assert_eq!( - queued_message_edit_binding_for_terminal(TerminalName::VsCode), + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::VsCode, + term_program: None, + version: None, + term: None, + multiplexer: None, + }), crate::key_hint::shift(KeyCode::Left) ); assert_eq!( - queued_message_edit_binding_for_terminal(TerminalName::Iterm2), + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::Iterm2, + term_program: None, + version: None, + term: None, + multiplexer: Some(Multiplexer::Tmux { version: None }), + }), + crate::key_hint::shift(KeyCode::Left) + ); + assert_eq!( + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::Iterm2, + term_program: None, + version: None, + term: None, + multiplexer: None, + }), crate::key_hint::alt(KeyCode::Up) ); } @@ -4064,6 +4292,107 @@ async fn steer_enter_queues_while_plan_stream_is_active() { assert!(drain_insert_history(&mut rx).is_empty()); } +#[tokio::test] +async fn submit_user_message_queues_while_compaction_turn_is_running() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + let thread_id = ThreadId::new(); + chat.thread_id = Some(thread_id); + chat.handle_server_notification( + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: AppServerTurn { + id: "turn-1".to_string(), + items: Vec::new(), + status: AppServerTurnStatus::InProgress, + error: None, + }, + }), + None, + ); + + chat.submit_user_message(UserMessage::from("queued while compacting")); + + assert_eq!(chat.pending_steers.len(), 1); + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "queued while compacting".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected running-turn compact steer submit, got {other:?}"), + } + + chat.handle_codex_event(Event { + id: "steer-rejected".into(), + msg: EventMsg::Error(ErrorEvent { + message: "cannot steer a compact turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }), + }), + }); + + assert!(chat.pending_steers.is_empty()); + assert_eq!( + chat.queued_user_message_texts(), + vec!["queued while compacting"] + ); + + chat.handle_server_notification( + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: AppServerTurn { + id: "turn-1".to_string(), + items: Vec::new(), + status: AppServerTurnStatus::Completed, + error: None, + }, + }), + None, + ); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "queued while compacting".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected queued compact follow-up Op::UserTurn, got {other:?}"), + } +} + +#[tokio::test] +async fn slash_compact_eagerly_queues_follow_up_before_turn_start() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + + chat.dispatch_command(SlashCommand::Compact); + + assert!(chat.bottom_pane.is_task_running()); + match rx.try_recv() { + Ok(AppEvent::CodexOp(Op::Compact)) => {} + other => panic!("expected compact op to be submitted, got {other:?}"), + } + + chat.bottom_pane.set_composer_text( + "queued before compact turn start".to_string(), + Vec::new(), + Vec::new(), + ); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(chat.pending_steers.is_empty()); + assert_eq!(chat.queued_user_messages.len(), 1); + assert_eq!( + chat.queued_user_messages.front().unwrap().text, + "queued before compact turn start" + ); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); +} + #[tokio::test] async fn steer_enter_uses_pending_steers_while_turn_is_running_without_streaming() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; @@ -11904,9 +12233,17 @@ async fn chatwidget_tall() { } #[tokio::test] -async fn enter_queues_user_messages_while_review_is_running() { +async fn enter_submits_steer_while_review_is_running() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; chat.thread_id = Some(ThreadId::new()); + chat.handle_codex_event(Event { + id: "turn-start".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); chat.handle_codex_event(Event { id: "review-1".into(), @@ -11918,19 +12255,28 @@ async fn enter_queues_user_messages_while_review_is_running() { let _ = drain_insert_history(&mut rx); chat.bottom_pane.set_composer_text( - "Queued while /review is running.".to_string(), + "Steer submitted while /review was running.".to_string(), Vec::new(), Vec::new(), ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(chat.queued_user_messages.len(), 1); + assert!(chat.queued_user_messages.is_empty()); + assert_eq!(chat.pending_steers.len(), 1); assert_eq!( - chat.queued_user_messages.front().unwrap().text, - "Queued while /review is running." + chat.pending_steers.front().unwrap().user_message.text, + "Steer submitted while /review was running." ); - assert!(chat.pending_steers.is_empty()); - assert_no_submit_op(&mut op_rx); + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "Steer submitted while /review was running.".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected running-turn steer submit, got {other:?}"), + } assert!(drain_insert_history(&mut rx).is_empty()); } @@ -11938,6 +12284,14 @@ async fn enter_queues_user_messages_while_review_is_running() { async fn review_queues_user_messages_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; chat.thread_id = Some(ThreadId::new()); + chat.handle_codex_event(Event { + id: "turn-start".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); chat.handle_codex_event(Event { id: "review-1".into(), @@ -11948,9 +12302,57 @@ async fn review_queues_user_messages_snapshot() { }); let _ = drain_insert_history(&mut rx); - chat.queue_user_message(UserMessage::from( - "Queued while /review is running.".to_string(), + chat.submit_user_message(UserMessage::from( + "Steer submitted while /review was running.".to_string(), )); + chat.handle_codex_event(Event { + id: "steer-rejected".into(), + msg: EventMsg::Error(ErrorEvent { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + }), + }); + + let width: u16 = 80; + let height: u16 = 18; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + let desired_height = chat.desired_height(width).min(height); + term.set_viewport_area(Rect::new(0, height - desired_height, width, desired_height)); + term.draw(|f| { + chat.render(f.area(), f.buffer_mut()); + }) + .unwrap(); + assert_snapshot!(term.backend().vt100().screen().contents()); +} + +#[tokio::test] +async fn compact_queues_user_messages_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + chat.handle_codex_event(Event { + id: "turn-start".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); + + chat.submit_user_message(UserMessage::from( + "Steer submitted while /compact was running.".to_string(), + )); + chat.handle_codex_event(Event { + id: "steer-rejected".into(), + msg: EventMsg::Error(ErrorEvent { + message: "cannot steer a compact turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }), + }), + }); let width: u16 = 80; let height: u16 = 18; diff --git a/codex-rs/tui_app_server/src/local_chatgpt_auth.rs b/codex-rs/tui_app_server/src/local_chatgpt_auth.rs index 6fbed6cc79..33d6e508a9 100644 --- a/codex-rs/tui_app_server/src/local_chatgpt_auth.rs +++ b/codex-rs/tui_app_server/src/local_chatgpt_auth.rs @@ -1,7 +1,6 @@ use std::path::Path; use codex_app_server_protocol::AuthMode; -use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse; use codex_core::auth::AuthCredentialsStoreMode; use codex_core::auth::load_auth_dot_json; @@ -12,16 +11,6 @@ pub(crate) struct LocalChatgptAuth { pub(crate) chatgpt_plan_type: Option, } -impl LocalChatgptAuth { - pub(crate) fn to_refresh_response(&self) -> ChatgptAuthTokensRefreshResponse { - ChatgptAuthTokensRefreshResponse { - access_token: self.access_token.clone(), - chatgpt_account_id: self.chatgpt_account_id.clone(), - chatgpt_plan_type: self.chatgpt_plan_type.clone(), - } - } -} - pub(crate) fn load_local_chatgpt_auth( codex_home: &Path, auth_credentials_store_mode: AuthCredentialsStoreMode, diff --git a/codex-rs/v8-poc/.gitignore b/codex-rs/v8-poc/.gitignore new file mode 100644 index 0000000000..8384df19c7 --- /dev/null +++ b/codex-rs/v8-poc/.gitignore @@ -0,0 +1,2 @@ +/target/ +/target-rs/ diff --git a/codex-rs/v8-poc/BUILD.bazel b/codex-rs/v8-poc/BUILD.bazel new file mode 100644 index 0000000000..0cadbd518d --- /dev/null +++ b/codex-rs/v8-poc/BUILD.bazel @@ -0,0 +1,12 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "v8-poc", + crate_name = "codex_v8_poc", + deps_extra = ["@crates//:v8"], +) + +alias( + name = "v8-poc-rusty-v8", + actual = ":v8-poc", +) diff --git a/codex-rs/v8-poc/Cargo.toml b/codex-rs/v8-poc/Cargo.toml new file mode 100644 index 0000000000..4bf008c095 --- /dev/null +++ b/codex-rs/v8-poc/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "codex-v8-poc" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_v8_poc" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +v8 = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/v8-poc/src/lib.rs b/codex-rs/v8-poc/src/lib.rs new file mode 100644 index 0000000000..0e9faaab10 --- /dev/null +++ b/codex-rs/v8-poc/src/lib.rs @@ -0,0 +1,65 @@ +//! Bazel-wired proof-of-concept crate reserved for future V8 experiments. + +/// Returns the Bazel label for this proof-of-concept crate. +#[must_use] +pub fn bazel_target() -> &'static str { + "//codex-rs/v8-poc:v8-poc" +} + +/// Returns the embedded V8 version. +#[must_use] +pub fn embedded_v8_version() -> &'static str { + v8::V8::get_version() +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use std::sync::Once; + + use super::bazel_target; + + fn initialize_v8() { + static INIT: Once = Once::new(); + + INIT.call_once(|| { + v8::V8::initialize_platform(v8::new_default_platform(0, false).make_shared()); + v8::V8::initialize(); + }); + } + + fn evaluate_expression(expression: &str) -> String { + initialize_v8(); + + let isolate = &mut v8::Isolate::new(Default::default()); + v8::scope!(let scope, isolate); + + let context = v8::Context::new(scope, Default::default()); + let scope = &mut v8::ContextScope::new(scope, context); + let source = v8::String::new(scope, expression).expect("expression should be valid UTF-8"); + let script = v8::Script::compile(scope, source, None).expect("expression should compile"); + let result = script.run(scope).expect("expression should evaluate"); + + result.to_rust_string_lossy(scope) + } + + #[test] + fn exposes_expected_bazel_target() { + assert_eq!(bazel_target(), "//codex-rs/v8-poc:v8-poc"); + } + + #[test] + fn exposes_embedded_v8_version() { + assert!(!super::embedded_v8_version().is_empty()); + } + + #[test] + fn evaluates_integer_addition() { + assert_eq!(evaluate_expression("1 + 2"), "3"); + } + + #[test] + fn evaluates_string_concatenation() { + assert_eq!(evaluate_expression("'hello ' + 'world'"), "hello world"); + } +} diff --git a/patches/BUILD.bazel b/patches/BUILD.bazel index 339c54a657..f308d260c0 100644 --- a/patches/BUILD.bazel +++ b/patches/BUILD.bazel @@ -1,5 +1,6 @@ exports_files([ "aws-lc-sys_memcmp_check.patch", + "rusty_v8_prebuilt_out_dir.patch", "v8_bazel_rules.patch", "v8_module_deps.patch", "v8_source_portability.patch", diff --git a/patches/aws-lc-sys_memcmp_check.patch b/patches/aws-lc-sys_memcmp_check.patch index beaa4cf85e..e9d0a441fd 100644 --- a/patches/aws-lc-sys_memcmp_check.patch +++ b/patches/aws-lc-sys_memcmp_check.patch @@ -10,7 +10,7 @@ diff --git a/builder/cc_builder.rs b/builder/cc_builder.rs #[non_exhaustive] #[derive(PartialEq, Eq)] -@@ -661,6 +661,16 @@ +@@ -681,6 +681,16 @@ } let mut memcmp_compile_args = Vec::from(memcmp_compiler.args()); @@ -27,7 +27,7 @@ diff --git a/builder/cc_builder.rs b/builder/cc_builder.rs // This check invokes the compiled executable and hence needs to link // it. CMake handles this via LDFLAGS but `cc` doesn't. In setups with // custom linker setups this could lead to a mismatch between the -@@ -672,6 +682,15 @@ +@@ -692,6 +702,15 @@ } } @@ -43,8 +43,8 @@ diff --git a/builder/cc_builder.rs b/builder/cc_builder.rs memcmp_compile_args.push( self.manifest_dir .join("aws-lc") -@@ -725,6 +744,40 @@ - } +@@ -742,6 +761,40 @@ + ); let _ = fs::remove_file(exec_path); } + @@ -84,3 +84,31 @@ diff --git a/builder/cc_builder.rs b/builder/cc_builder.rs fn run_compiler_checks(&self, cc_build: &mut cc::Build) { if self.compiler_check("stdalign_check", Vec::<&'static str>::new()) { cc_build.define("AWS_LC_STDALIGN_AVAILABLE", Some("1")); +diff --git a/builder/main.rs b/builder/main.rs +--- a/builder/main.rs ++++ b/builder/main.rs +@@ -944,10 +944,12 @@ + // iterate over all the include paths and copy them into the final output + for path in include_paths { + for child in std::fs::read_dir(path).into_iter().flatten().flatten() { +- if child.file_type().map_or(false, |t| t.is_file()) { ++ let child_path = child.path(); ++ ++ if child_path.is_file() { + std::fs::copy( +- child.path(), +- include_dir.join(child.path().file_name().unwrap()), ++ &child_path, ++ include_dir.join(child_path.file_name().unwrap()), + ) + .expect("Failed to copy include file during build setup"); + continue; +@@ -957,7 +959,7 @@ + let options = fs_extra::dir::CopyOptions::new() + .skip_exist(true) + .copy_inside(true); +- fs_extra::dir::copy(child.path(), &include_dir, &options) ++ fs_extra::dir::copy(child_path, &include_dir, &options) + .expect("Failed to copy include directory during build setup"); + } + } diff --git a/patches/rusty_v8_prebuilt_out_dir.patch b/patches/rusty_v8_prebuilt_out_dir.patch new file mode 100644 index 0000000000..e5132d664f --- /dev/null +++ b/patches/rusty_v8_prebuilt_out_dir.patch @@ -0,0 +1,52 @@ +--- a/build.rs ++++ b/build.rs +@@ -577,7 +577,23 @@ + path + } + ++fn out_dir_abs() -> PathBuf { ++ let cwd = env::current_dir().unwrap(); ++ ++ // target/debug/build/rusty_v8-d9e5a424d4f96994/out/ ++ let out_dir = env::var_os("OUT_DIR").expect( ++ "The 'OUT_DIR' environment is not set (it should be something like \ ++ 'target/debug/rusty_v8-{hash}').", ++ ); ++ ++ cwd.join(out_dir) ++} ++ + fn static_lib_dir() -> PathBuf { ++ if env::var_os("RUSTY_V8_ARCHIVE").is_some() { ++ return out_dir_abs().join("gn_out").join("obj"); ++ } ++ + build_dir().join("gn_out").join("obj") + } + +@@ -794,22 +810,23 @@ + } + + fn print_link_flags() { ++ let target = env::var("TARGET").unwrap(); + println!("cargo:rustc-link-lib=static=rusty_v8"); + let should_dyn_link_libcxx = env::var("CARGO_FEATURE_USE_CUSTOM_LIBCXX") + .is_err() ++ || (target.contains("apple") && env::var("RUSTY_V8_ARCHIVE").is_ok()) + || env::var("GN_ARGS").is_ok_and(|gn_args| { + gn_args + .split_whitespace() + .any(|ba| ba == "use_custom_libcxx=false") + }); + + if should_dyn_link_libcxx { + // Based on https://github.com/alexcrichton/cc-rs/blob/fba7feded71ee4f63cfe885673ead6d7b4f2f454/src/lib.rs#L2462 + if let Ok(stdlib) = env::var("CXXSTDLIB") { + if !stdlib.is_empty() { + println!("cargo:rustc-link-lib=dylib={stdlib}"); + } + } else { +- let target = env::var("TARGET").unwrap(); + if target.contains("msvc") { + // nothing to link to + } else if target.contains("apple") diff --git a/patches/v8_bazel_rules.patch b/patches/v8_bazel_rules.patch index 0596ea8396..66aff5e4d2 100644 --- a/patches/v8_bazel_rules.patch +++ b/patches/v8_bazel_rules.patch @@ -121,7 +121,7 @@ index 85f31b7..7314584 100644 ], outs = [ "include/inspector/Debugger.h", -@@ -4426,15 +4426,19 @@ genrule( +@@ -4426,15 +4426,18 @@ genrule( "src/inspector/protocol/Schema.cpp", "src/inspector/protocol/Schema.h", ], @@ -134,7 +134,7 @@ index 85f31b7..7314584 100644 --config $(location :src/inspector/inspector_protocol_config.json) \ --config_value protocol.path=$(location :include/js_protocol.pdl) \ --output_base $(@D)/src/inspector", - local = 1, +- local = 1, message = "Generating inspector files", tools = [ - ":code_generator", diff --git a/scripts/test-remote-env.sh b/scripts/test-remote-env.sh new file mode 100755 index 0000000000..60fb447832 --- /dev/null +++ b/scripts/test-remote-env.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash + +# Remote-env setup script for codex-rs integration tests. +# +# Usage (source-only): +# source scripts/test-remote-env.sh +# cd codex-rs +# cargo test -p codex-core --test all remote_env_connects_creates_temp_dir_and_runs_sample_script +# codex_remote_env_cleanup + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +is_sourced() { + [[ "${BASH_SOURCE[0]}" != "$0" ]] +} + +setup_remote_env() { + local container_name + local codex_exec_server_binary_path + + container_name="${CODEX_TEST_REMOTE_ENV_CONTAINER_NAME:-codex-remote-test-env-local-$(date +%s)-${RANDOM}}" + codex_exec_server_binary_path="${REPO_ROOT}/codex-rs/target/debug/codex-exec-server" + + if ! command -v docker >/dev/null 2>&1; then + echo "docker is required (Colima or Docker Desktop)" >&2 + return 1 + fi + + if ! docker info >/dev/null 2>&1; then + echo "docker daemon is not reachable; for Colima run: colima start" >&2 + return 1 + fi + + if ! command -v cargo >/dev/null 2>&1; then + echo "cargo is required to build codex-exec-server" >&2 + return 1 + fi + + ( + cd "${REPO_ROOT}/codex-rs" + cargo build -p codex-exec-server --bin codex-exec-server + ) + + if [[ ! -f "${codex_exec_server_binary_path}" ]]; then + echo "codex-exec-server binary not found at ${codex_exec_server_binary_path}" >&2 + return 1 + fi + + docker rm -f "${container_name}" >/dev/null 2>&1 || true + docker run -d --name "${container_name}" ubuntu:24.04 sleep infinity >/dev/null + + export CODEX_TEST_REMOTE_ENV="${container_name}" +} + +codex_remote_env_cleanup() { + if [[ -n "${CODEX_TEST_REMOTE_ENV:-}" ]]; then + docker rm -f "${CODEX_TEST_REMOTE_ENV}" >/dev/null 2>&1 || true + unset CODEX_TEST_REMOTE_ENV + fi +} + +if ! is_sourced; then + echo "source this script instead of executing it: source scripts/test-remote-env.sh" >&2 + exit 1 +fi + +old_shell_options="$(set +o)" +set -euo pipefail +if setup_remote_env; then + status=0 + echo "CODEX_TEST_REMOTE_ENV=${CODEX_TEST_REMOTE_ENV}" + echo "Remote env ready. Run your command, then call: codex_remote_env_cleanup" +else + status=$? +fi +eval "${old_shell_options}" +return "${status}" diff --git a/sdk/python/src/codex_app_server/client.py b/sdk/python/src/codex_app_server/client.py index aa7b574a3f..146d5186e1 100644 --- a/sdk/python/src/codex_app_server/client.py +++ b/sdk/python/src/codex_app_server/client.py @@ -181,6 +181,7 @@ class AppServerClient: stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + encoding="utf-8", cwd=self.config.cwd, env=env, bufsize=1, diff --git a/third_party/v8/BUILD.bazel b/third_party/v8/BUILD.bazel index cfdbabf468..b32bc2f495 100644 --- a/third_party/v8/BUILD.bazel +++ b/third_party/v8/BUILD.bazel @@ -4,6 +4,158 @@ load("@rules_cc//cc:defs.bzl", "cc_library") package(default_visibility = ["//visibility:public"]) +config_setting( + name = "platform_aarch64_unknown_linux_musl", + constraint_values = [ + "@platforms//cpu:aarch64", + "@platforms//os:linux", + "@llvm//constraints/libc:musl", + ], +) + +config_setting( + name = "platform_x86_64_unknown_linux_musl", + constraint_values = [ + "@platforms//cpu:x86_64", + "@platforms//os:linux", + "@llvm//constraints/libc:musl", + ], +) + +alias( + name = "v8_146_4_0_x86_64_apple_darwin", + actual = "@rusty_v8_146_4_0_x86_64_apple_darwin_archive//file", +) + +alias( + name = "v8_146_4_0_aarch64_apple_darwin", + actual = "@rusty_v8_146_4_0_aarch64_apple_darwin_archive//file", +) + +alias( + name = "v8_146_4_0_x86_64_unknown_linux_gnu", + actual = "@rusty_v8_146_4_0_x86_64_unknown_linux_gnu_archive//file", +) + +alias( + name = "v8_146_4_0_aarch64_unknown_linux_gnu", + actual = "@rusty_v8_146_4_0_aarch64_unknown_linux_gnu_archive//file", +) + +alias( + name = "v8_146_4_0_x86_64_unknown_linux_musl", + actual = "@rusty_v8_146_4_0_x86_64_unknown_linux_musl_archive//file", +) + +alias( + name = "v8_146_4_0_aarch64_unknown_linux_musl", + actual = "@rusty_v8_146_4_0_aarch64_unknown_linux_musl_archive//file", +) + +alias( + name = "v8_146_4_0_x86_64_pc_windows_msvc", + actual = "@rusty_v8_146_4_0_x86_64_pc_windows_msvc_archive//file", +) + +alias( + name = "v8_146_4_0_aarch64_pc_windows_msvc", + actual = "@rusty_v8_146_4_0_aarch64_pc_windows_msvc_archive//file", +) + +alias( + name = "v8_146_4_0_aarch64_pc_windows_gnullvm", + actual = ":v8_146_4_0_aarch64_pc_windows_msvc", +) + +alias( + name = "v8_146_4_0_x86_64_pc_windows_gnullvm", + actual = ":v8_146_4_0_x86_64_pc_windows_msvc", +) + +filegroup( + name = "src_binding_release_x86_64_apple_darwin", + srcs = ["@v8_crate_146_4_0//:src_binding_release_x86_64_apple_darwin"], +) + +filegroup( + name = "src_binding_release_aarch64_apple_darwin", + srcs = ["@v8_crate_146_4_0//:src_binding_release_aarch64_apple_darwin"], +) + +filegroup( + name = "src_binding_release_aarch64_unknown_linux_gnu", + srcs = ["@v8_crate_146_4_0//:src_binding_release_aarch64_unknown_linux_gnu"], +) + +filegroup( + name = "src_binding_release_x86_64_unknown_linux_gnu", + srcs = ["@v8_crate_146_4_0//:src_binding_release_x86_64_unknown_linux_gnu"], +) + +alias( + name = "src_binding_release_x86_64_unknown_linux_musl", + actual = "@rusty_v8_146_4_0_x86_64_unknown_linux_musl_binding//file", +) + +alias( + name = "src_binding_release_aarch64_unknown_linux_musl", + actual = "@rusty_v8_146_4_0_aarch64_unknown_linux_musl_binding//file", +) + +filegroup( + name = "src_binding_release_x86_64_pc_windows_msvc", + srcs = ["@v8_crate_146_4_0//:src_binding_release_x86_64_pc_windows_msvc"], +) + +filegroup( + name = "src_binding_release_aarch64_pc_windows_msvc", + srcs = ["@v8_crate_146_4_0//:src_binding_release_aarch64_pc_windows_msvc"], +) + +alias( + name = "src_binding_release_x86_64_pc_windows_gnullvm", + actual = ":src_binding_release_x86_64_pc_windows_msvc", +) + +alias( + name = "src_binding_release_aarch64_pc_windows_gnullvm", + actual = ":src_binding_release_aarch64_pc_windows_msvc", +) + +alias( + name = "rusty_v8_archive_for_target", + actual = select({ + "@rules_rs//rs/experimental/platforms/config:aarch64-apple-darwin": ":v8_146_4_0_aarch64_apple_darwin_bazel", + "@rules_rs//rs/experimental/platforms/config:aarch64-pc-windows-gnullvm": ":v8_146_4_0_aarch64_pc_windows_gnullvm", + "@rules_rs//rs/experimental/platforms/config:aarch64-pc-windows-msvc": ":v8_146_4_0_aarch64_pc_windows_msvc", + "@rules_rs//rs/experimental/platforms/config:aarch64-unknown-linux-gnu": ":v8_146_4_0_aarch64_unknown_linux_gnu_bazel", + ":platform_aarch64_unknown_linux_musl": ":v8_146_4_0_aarch64_unknown_linux_musl_release_base", + "@rules_rs//rs/experimental/platforms/config:x86_64-apple-darwin": ":v8_146_4_0_x86_64_apple_darwin_bazel", + "@rules_rs//rs/experimental/platforms/config:x86_64-pc-windows-gnullvm": ":v8_146_4_0_x86_64_pc_windows_gnullvm", + "@rules_rs//rs/experimental/platforms/config:x86_64-pc-windows-msvc": ":v8_146_4_0_x86_64_pc_windows_msvc", + "@rules_rs//rs/experimental/platforms/config:x86_64-unknown-linux-gnu": ":v8_146_4_0_x86_64_unknown_linux_gnu_bazel", + ":platform_x86_64_unknown_linux_musl": ":v8_146_4_0_x86_64_unknown_linux_musl_release", + "//conditions:default": ":v8_146_4_0_x86_64_unknown_linux_gnu_bazel", + }), +) + +alias( + name = "rusty_v8_binding_for_target", + actual = select({ + "@rules_rs//rs/experimental/platforms/config:aarch64-apple-darwin": ":src_binding_release_aarch64_apple_darwin", + "@rules_rs//rs/experimental/platforms/config:aarch64-pc-windows-gnullvm": ":src_binding_release_aarch64_pc_windows_gnullvm", + "@rules_rs//rs/experimental/platforms/config:aarch64-pc-windows-msvc": ":src_binding_release_aarch64_pc_windows_msvc", + "@rules_rs//rs/experimental/platforms/config:aarch64-unknown-linux-gnu": ":src_binding_release_aarch64_unknown_linux_gnu", + ":platform_aarch64_unknown_linux_musl": ":src_binding_release_aarch64_unknown_linux_musl", + "@rules_rs//rs/experimental/platforms/config:x86_64-apple-darwin": ":src_binding_release_x86_64_apple_darwin", + "@rules_rs//rs/experimental/platforms/config:x86_64-pc-windows-gnullvm": ":src_binding_release_x86_64_pc_windows_gnullvm", + "@rules_rs//rs/experimental/platforms/config:x86_64-pc-windows-msvc": ":src_binding_release_x86_64_pc_windows_msvc", + "@rules_rs//rs/experimental/platforms/config:x86_64-unknown-linux-gnu": ":src_binding_release_x86_64_unknown_linux_gnu", + ":platform_x86_64_unknown_linux_musl": ":src_binding_release_x86_64_unknown_linux_musl", + "//conditions:default": ":src_binding_release_x86_64_unknown_linux_gnu", + }), +) + V8_COPTS = ["-std=c++20"] V8_STATIC_LIBRARY_FEATURES = [ @@ -45,39 +197,39 @@ cc_library( ) cc_static_library( - name = "v8_146_4_0_x86_64_apple_darwin", + name = "v8_146_4_0_aarch64_apple_darwin_bazel", deps = [":v8_146_4_0_binding"], features = V8_STATIC_LIBRARY_FEATURES, ) cc_static_library( - name = "v8_146_4_0_aarch64_apple_darwin", + name = "v8_146_4_0_aarch64_unknown_linux_gnu_bazel", deps = [":v8_146_4_0_binding"], features = V8_STATIC_LIBRARY_FEATURES, ) cc_static_library( - name = "v8_146_4_0_aarch64_unknown_linux_gnu", + name = "v8_146_4_0_x86_64_apple_darwin_bazel", deps = [":v8_146_4_0_binding"], features = V8_STATIC_LIBRARY_FEATURES, ) cc_static_library( - name = "v8_146_4_0_x86_64_unknown_linux_gnu", + name = "v8_146_4_0_x86_64_unknown_linux_gnu_bazel", deps = [":v8_146_4_0_binding"], features = V8_STATIC_LIBRARY_FEATURES, ) cc_static_library( - name = "v8_146_4_0_aarch64_unknown_linux_musl_base", + name = "v8_146_4_0_aarch64_unknown_linux_musl_release_base", deps = [":v8_146_4_0_binding"], features = V8_STATIC_LIBRARY_FEATURES, ) genrule( - name = "v8_146_4_0_aarch64_unknown_linux_musl", + name = "v8_146_4_0_aarch64_unknown_linux_musl_release", srcs = [ - ":v8_146_4_0_aarch64_unknown_linux_musl_base", + ":v8_146_4_0_aarch64_unknown_linux_musl_release_base", "@llvm//runtimes/compiler-rt:clang_rt.builtins.static", ], tools = [ @@ -88,7 +240,7 @@ genrule( cmd = """ cat > "$(@D)/merge.mri" <<'EOF' create $@ -addlib $(location :v8_146_4_0_aarch64_unknown_linux_musl_base) +addlib $(location :v8_146_4_0_aarch64_unknown_linux_musl_release_base) addlib $(location @llvm//runtimes/compiler-rt:clang_rt.builtins.static) save end @@ -99,83 +251,21 @@ EOF ) cc_static_library( - name = "v8_146_4_0_x86_64_unknown_linux_musl", + name = "v8_146_4_0_x86_64_unknown_linux_musl_release", deps = [":v8_146_4_0_binding"], features = V8_STATIC_LIBRARY_FEATURES, ) -cc_static_library( - name = "v8_146_4_0_aarch64_pc_windows_msvc", - deps = [":v8_146_4_0_binding"], - features = V8_STATIC_LIBRARY_FEATURES, -) - -cc_static_library( - name = "v8_146_4_0_x86_64_pc_windows_msvc", - deps = [":v8_146_4_0_binding"], - features = V8_STATIC_LIBRARY_FEATURES, -) - -alias( - name = "v8_146_4_0_aarch64_pc_windows_gnullvm", - actual = ":v8_146_4_0_aarch64_pc_windows_msvc", -) - -alias( - name = "v8_146_4_0_x86_64_pc_windows_gnullvm", - actual = ":v8_146_4_0_x86_64_pc_windows_msvc", -) - filegroup( - name = "src_binding_release_x86_64_apple_darwin", - srcs = ["@v8_crate_146_4_0//:src_binding_release_x86_64_apple_darwin"], -) - -filegroup( - name = "src_binding_release_aarch64_apple_darwin", - srcs = ["@v8_crate_146_4_0//:src_binding_release_aarch64_apple_darwin"], -) - -filegroup( - name = "src_binding_release_aarch64_unknown_linux_gnu", + name = "src_binding_release_aarch64_unknown_linux_musl_release", srcs = ["@v8_crate_146_4_0//:src_binding_release_aarch64_unknown_linux_gnu"], ) filegroup( - name = "src_binding_release_x86_64_unknown_linux_gnu", + name = "src_binding_release_x86_64_unknown_linux_musl_release", srcs = ["@v8_crate_146_4_0//:src_binding_release_x86_64_unknown_linux_gnu"], ) -filegroup( - name = "src_binding_release_aarch64_unknown_linux_musl", - srcs = ["@v8_crate_146_4_0//:src_binding_release_aarch64_unknown_linux_gnu"], -) - -filegroup( - name = "src_binding_release_x86_64_unknown_linux_musl", - srcs = ["@v8_crate_146_4_0//:src_binding_release_x86_64_unknown_linux_gnu"], -) - -filegroup( - name = "src_binding_release_x86_64_pc_windows_msvc", - srcs = ["@v8_crate_146_4_0//:src_binding_release_x86_64_pc_windows_msvc"], -) - -filegroup( - name = "src_binding_release_aarch64_pc_windows_msvc", - srcs = ["@v8_crate_146_4_0//:src_binding_release_aarch64_pc_windows_msvc"], -) - -alias( - name = "src_binding_release_x86_64_pc_windows_gnullvm", - actual = ":src_binding_release_x86_64_pc_windows_msvc", -) - -alias( - name = "src_binding_release_aarch64_pc_windows_gnullvm", - actual = ":src_binding_release_aarch64_pc_windows_msvc", -) - filegroup( name = "rusty_v8_release_pair_x86_64_apple_darwin", srcs = [ @@ -211,16 +301,16 @@ filegroup( filegroup( name = "rusty_v8_release_pair_x86_64_unknown_linux_musl", srcs = [ - ":v8_146_4_0_x86_64_unknown_linux_musl", - ":src_binding_release_x86_64_unknown_linux_musl", + ":v8_146_4_0_x86_64_unknown_linux_musl_release", + ":src_binding_release_x86_64_unknown_linux_musl_release", ], ) filegroup( name = "rusty_v8_release_pair_aarch64_unknown_linux_musl", srcs = [ - ":v8_146_4_0_aarch64_unknown_linux_musl", - ":src_binding_release_aarch64_unknown_linux_musl", + ":v8_146_4_0_aarch64_unknown_linux_musl_release", + ":src_binding_release_aarch64_unknown_linux_musl_release", ], ) diff --git a/third_party/v8/README.md b/third_party/v8/README.md index 3931bbca46..9ad37c6f08 100644 --- a/third_party/v8/README.md +++ b/third_party/v8/README.md @@ -1,45 +1,47 @@ -# `rusty_v8` Release Artifacts +# `rusty_v8` Consumer Artifacts -This directory contains the Bazel packaging used to build and stage -target-specific `rusty_v8` release artifacts for Bazel-managed consumers. +This directory wires the `v8` crate to exact-version Bazel inputs. +Bazel consumer builds use: + +- upstream `denoland/rusty_v8` release archives on Windows +- source-built V8 archives on Darwin, GNU Linux, and musl Linux +- `openai/codex` release assets for published musl release pairs + +Cargo builds still use prebuilt `rusty_v8` archives by default. Only Bazel +overrides `RUSTY_V8_ARCHIVE`/`RUSTY_V8_SRC_BINDING_PATH` in `MODULE.bazel` to +select source-built local archives for its consumer builds. Current pinned versions: - Rust crate: `v8 = =146.4.0` -- Embedded upstream V8 source: `14.6.202.9` +- Embedded upstream V8 source for musl release builds: `14.6.202.9` -The generated release pairs include: +The consumer-facing selectors are: + +- `//third_party/v8:rusty_v8_archive_for_target` +- `//third_party/v8:rusty_v8_binding_for_target` + +Musl release assets are expected at the tag: + +- `rusty-v8-v` + +with these raw asset names: + +- `librusty_v8_release_.a.gz` +- `src_binding_release_.rs` + +The dedicated publishing workflow is `.github/workflows/rusty-v8-release.yml`. +It builds musl release pairs from source and keeps the release artifacts as the +statically linked form: -- `//third_party/v8:rusty_v8_release_pair_x86_64_apple_darwin` -- `//third_party/v8:rusty_v8_release_pair_aarch64_apple_darwin` -- `//third_party/v8:rusty_v8_release_pair_x86_64_unknown_linux_gnu` -- `//third_party/v8:rusty_v8_release_pair_aarch64_unknown_linux_gnu` - `//third_party/v8:rusty_v8_release_pair_x86_64_unknown_linux_musl` - `//third_party/v8:rusty_v8_release_pair_aarch64_unknown_linux_musl` -- `//third_party/v8:rusty_v8_release_pair_x86_64_pc_windows_msvc` -- `//third_party/v8:rusty_v8_release_pair_aarch64_pc_windows_msvc` -Each release pair contains: - -- a static library built from source -- a Rust binding file copied from the exact same `v8` crate version for that - target +Cargo musl builds use `RUSTY_V8_ARCHIVE` plus a downloaded +`RUSTY_V8_SRC_BINDING_PATH` to point at those `openai/codex` release assets +directly. We do not use `RUSTY_V8_MIRROR` for musl because the upstream `v8` +crate hardcodes a `v` tag layout, while our musl artifacts are +published under `rusty-v8-v`. Do not mix artifacts across crate versions. The archive and binding must match -the exact pinned `v8` crate version used by this repo. - -The dedicated publishing workflow is: - -- `.github/workflows/rusty-v8-release.yml` - -That workflow currently stages musl artifacts: - -- `librusty_v8_release_x86_64-unknown-linux-musl.a.gz` -- `librusty_v8_release_aarch64-unknown-linux-musl.a.gz` -- `src_binding_release_x86_64-unknown-linux-musl.rs` -- `src_binding_release_aarch64-unknown-linux-musl.rs` - -During musl staging, the produced static archive is merged with the target's -LLVM `libc++` and `libc++abi` static runtime archives. Rust's musl toolchain -already provides the matching `libunwind`, so staging does not bundle a second -copy. +the exact resolved `v8` crate version in `codex-rs/Cargo.lock`.