mirror of
https://github.com/openai/codex.git
synced 2026-10-22 17:58:46 +00:00
Merge remote-tracking branch 'origin/main' into codex/viyatb/issue-12572-nested-proxy-netns-fallback
# Conflicts: # MODULE.bazel.lock
This commit is contained in:
@@ -107,6 +107,45 @@ jobs:
|
||||
BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
|
||||
bazel_console_log="$(mktemp)"
|
||||
|
||||
print_failed_bazel_test_logs() {
|
||||
local console_log="$1"
|
||||
local testlogs_dir
|
||||
|
||||
testlogs_dir="$(bazel $BAZEL_STARTUP_ARGS info bazel-testlogs 2>/dev/null || echo bazel-testlogs)"
|
||||
|
||||
local failed_targets=()
|
||||
while IFS= read -r target; do
|
||||
failed_targets+=("$target")
|
||||
done < <(
|
||||
grep -E '^FAIL: //' "$console_log" \
|
||||
| sed -E 's#^FAIL: (//[^ ]+).*#\1#' \
|
||||
| sort -u
|
||||
)
|
||||
|
||||
if [[ ${#failed_targets[@]} -eq 0 ]]; then
|
||||
echo "No failed Bazel test targets were found in console output."
|
||||
return
|
||||
fi
|
||||
|
||||
for target in "${failed_targets[@]}"; do
|
||||
local rel_path="${target#//}"
|
||||
rel_path="${rel_path/:/\/}"
|
||||
local test_log="${testlogs_dir}/${rel_path}/test.log"
|
||||
|
||||
echo "::group::Bazel test log tail for ${target}"
|
||||
if [[ -f "$test_log" ]]; then
|
||||
tail -n 200 "$test_log"
|
||||
else
|
||||
echo "Missing test log: $test_log"
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
done
|
||||
}
|
||||
|
||||
bazel_args=(
|
||||
test
|
||||
//...
|
||||
@@ -119,10 +158,19 @@ jobs:
|
||||
|
||||
if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then
|
||||
echo "BuildBuddy API key is available; using remote Bazel configuration."
|
||||
# Work around Bazel 9 remote repo contents cache / overlay materialization failures
|
||||
# seen in CI (for example "is not a symlink" or permission errors while
|
||||
# materializing external repos such as rules_perl). We still use BuildBuddy for
|
||||
# remote execution/cache; this only disables the startup-level repo contents cache.
|
||||
set +e
|
||||
bazel $BAZEL_STARTUP_ARGS \
|
||||
--noexperimental_remote_repo_contents_cache \
|
||||
--bazelrc=.github/workflows/ci.bazelrc \
|
||||
"${bazel_args[@]}" \
|
||||
"--remote_header=x-buildbuddy-api-key=$BUILDBUDDY_API_KEY"
|
||||
"--remote_header=x-buildbuddy-api-key=$BUILDBUDDY_API_KEY" \
|
||||
2>&1 | tee "$bazel_console_log"
|
||||
bazel_status=${PIPESTATUS[0]}
|
||||
set -e
|
||||
else
|
||||
echo "BuildBuddy API key is not available; using local Bazel configuration."
|
||||
# Keep fork/community PRs on Bazel but disable remote services that are
|
||||
@@ -141,9 +189,18 @@ jobs:
|
||||
# clear remote cache/execution endpoints configured in .bazelrc.
|
||||
# https://bazel.build/reference/command-line-reference#common_options-flag--remote_cache
|
||||
# https://bazel.build/reference/command-line-reference#common_options-flag--remote_executor
|
||||
set +e
|
||||
bazel $BAZEL_STARTUP_ARGS \
|
||||
--noexperimental_remote_repo_contents_cache \
|
||||
"${bazel_args[@]}" \
|
||||
--remote_cache= \
|
||||
--remote_executor=
|
||||
--remote_executor= \
|
||||
2>&1 | tee "$bazel_console_log"
|
||||
bazel_status=${PIPESTATUS[0]}
|
||||
set -e
|
||||
fi
|
||||
|
||||
if [[ ${bazel_status:-0} -ne 0 ]]; then
|
||||
print_failed_bazel_test_logs "$bazel_console_log"
|
||||
exit "$bazel_status"
|
||||
fi
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
load("@apple_support//xcode:xcode_config.bzl", "xcode_config")
|
||||
load("@rules_cc//cc:defs.bzl", "cc_shared_library")
|
||||
|
||||
cc_shared_library(
|
||||
name = "clang",
|
||||
deps = ["@llvm-project//clang:libclang"],
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
xcode_config(name = "disable_xcode")
|
||||
|
||||
|
||||
+49
-8
@@ -1,5 +1,7 @@
|
||||
module(name = "codex")
|
||||
|
||||
bazel_dep(name = "platforms", version = "1.0.0")
|
||||
bazel_dep(name = "toolchains_llvm_bootstrapped", version = "0.5.3")
|
||||
bazel_dep(name = "toolchains_llvm_bootstrapped", version = "0.5.6")
|
||||
single_version_override(
|
||||
module_name = "toolchains_llvm_bootstrapped",
|
||||
patch_strip = 1,
|
||||
@@ -8,6 +10,8 @@ single_version_override(
|
||||
],
|
||||
)
|
||||
|
||||
register_toolchains("@toolchains_llvm_bootstrapped//toolchain:all")
|
||||
|
||||
osx = use_extension("@toolchains_llvm_bootstrapped//extensions:osx.bzl", "osx")
|
||||
osx.framework(name = "ApplicationServices")
|
||||
osx.framework(name = "AppKit")
|
||||
@@ -16,8 +20,12 @@ 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")
|
||||
@@ -25,10 +33,7 @@ osx.framework(name = "Kernel")
|
||||
osx.framework(name = "OSLog")
|
||||
osx.framework(name = "Security")
|
||||
osx.framework(name = "SystemConfiguration")
|
||||
|
||||
register_toolchains(
|
||||
"@toolchains_llvm_bootstrapped//toolchain:all",
|
||||
)
|
||||
use_repo(osx, "macosx15.4.sdk")
|
||||
|
||||
# Needed to disable xcode...
|
||||
bazel_dep(name = "apple_support", version = "2.1.0")
|
||||
@@ -39,9 +44,9 @@ bazel_dep(name = "rules_rs", version = "0.0.23")
|
||||
# Special toolchains branch
|
||||
archive_override(
|
||||
module_name = "rules_rs",
|
||||
integrity = "sha256-YbDRjZos4UmfIPY98znK1BgBWRQ1/ui3CtL6RqxE30I=",
|
||||
strip_prefix = "rules_rs-6cf3d940fdc48baf3ebd6c37daf8e0be8fc73ecb",
|
||||
url = "https://github.com/dzbarsky/rules_rs/archive/6cf3d940fdc48baf3ebd6c37daf8e0be8fc73ecb.tar.gz",
|
||||
integrity = "sha256-O34UF4H7b1Qacu3vlu2Od4ILGVApzg5j1zl952SFL3w=",
|
||||
strip_prefix = "rules_rs-097123c2aa72672e371e69e7035869f5a45c7b2b",
|
||||
url = "https://github.com/dzbarsky/rules_rs/archive/097123c2aa72672e371e69e7035869f5a45c7b2b.tar.gz",
|
||||
)
|
||||
|
||||
rules_rust = use_extension("@rules_rs//rs/experimental:rules_rust.bzl", "rules_rust")
|
||||
@@ -134,6 +139,9 @@ crate.annotation(
|
||||
"OPENSSL_NO_VENDOR": "1",
|
||||
"OPENSSL_STATIC": "1",
|
||||
},
|
||||
crate_features = [
|
||||
"dep:openssl-src",
|
||||
],
|
||||
crate = "openssl-sys",
|
||||
data = ["@openssl//:gen_dir"],
|
||||
)
|
||||
@@ -145,6 +153,28 @@ crate.annotation(
|
||||
workspace_cargo_toml = "rust/runfiles/Cargo.toml",
|
||||
)
|
||||
|
||||
llvm = use_extension("@toolchains_llvm_bootstrapped//extensions:llvm.bzl", "llvm")
|
||||
use_repo(llvm, "llvm-project")
|
||||
|
||||
crate.annotation(
|
||||
# Provide the hermetic SDK path so the build script doesn't try to invoke an unhermetic `xcrun --show-sdk-path`.
|
||||
build_script_data = [
|
||||
"@macosx15.4.sdk//sysroot",
|
||||
],
|
||||
build_script_env = {
|
||||
"BINDGEN_EXTRA_CLANG_ARGS": "-isystem $(location @toolchains_llvm_bootstrapped//:builtin_headers)",
|
||||
"COREAUDIO_SDK_PATH": "$(location @macosx15.4.sdk//sysroot)",
|
||||
"LIBCLANG_PATH": "$(location @codex//:clang)",
|
||||
},
|
||||
build_script_tools = [
|
||||
"@codex//:clang",
|
||||
"@toolchains_llvm_bootstrapped//:builtin_headers",
|
||||
],
|
||||
crate = "coreaudio-sys",
|
||||
)
|
||||
|
||||
inject_repo(crate, "codex", "toolchains_llvm_bootstrapped", "macosx15.4.sdk")
|
||||
|
||||
# Fix readme inclusions
|
||||
crate.annotation(
|
||||
crate = "windows-link",
|
||||
@@ -175,6 +205,17 @@ crate.annotation(
|
||||
gen_build_script = "off",
|
||||
deps = [":windows_import_lib"],
|
||||
)
|
||||
|
||||
bazel_dep(name = "alsa_lib", version = "1.2.9.bcr.4")
|
||||
|
||||
crate.annotation(
|
||||
crate = "alsa-sys",
|
||||
gen_build_script = "off",
|
||||
deps = ["@alsa_lib"],
|
||||
)
|
||||
|
||||
inject_repo(crate, "alsa_lib")
|
||||
|
||||
use_repo(crate, "crates")
|
||||
|
||||
rbe_platform_repository = use_repo_rule("//:rbe.bzl", "rbe_platform_repository")
|
||||
|
||||
Generated
+14
-11
@@ -14,6 +14,8 @@
|
||||
"https://bcr.bazel.build/modules/abseil-cpp/20250512.1/MODULE.bazel": "d209fdb6f36ffaf61c509fcc81b19e81b411a999a934a032e10cd009a0226215",
|
||||
"https://bcr.bazel.build/modules/abseil-cpp/20250814.1/MODULE.bazel": "51f2312901470cdab0dbdf3b88c40cd21c62a7ed58a3de45b365ddc5b11bcab2",
|
||||
"https://bcr.bazel.build/modules/abseil-cpp/20250814.1/source.json": "cea3901d7e299da7320700abbaafe57a65d039f10d0d7ea601c4a66938ea4b0c",
|
||||
"https://bcr.bazel.build/modules/alsa_lib/1.2.9.bcr.4/MODULE.bazel": "66842efc2b50b7c12274a5218d468119a5d6f9dc46a5164d9496fb517f64aba6",
|
||||
"https://bcr.bazel.build/modules/alsa_lib/1.2.9.bcr.4/source.json": "118ea7208f2fab61b4b14cc763a6fb4e5931bc4d3cec30b83c749a3134463752",
|
||||
"https://bcr.bazel.build/modules/apple_support/1.11.1/MODULE.bazel": "1843d7cd8a58369a444fc6000e7304425fba600ff641592161d9f15b179fb896",
|
||||
"https://bcr.bazel.build/modules/apple_support/1.15.1/MODULE.bazel": "a0556fefca0b1bb2de8567b8827518f94db6a6e7e7d632b4c48dc5f865bc7c85",
|
||||
"https://bcr.bazel.build/modules/apple_support/1.21.0/MODULE.bazel": "ac1824ed5edf17dee2fdd4927ada30c9f8c3b520be1b5fd02a5da15bc10bff3e",
|
||||
@@ -49,7 +51,8 @@
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.9.0/MODULE.bazel": "885151d58d90d8d9c811eb75e3288c11f850e1d6b481a8c9f766adee4712358b",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a",
|
||||
"https://bcr.bazel.build/modules/bazel_lib/3.0.0/MODULE.bazel": "22b70b80ac89ad3f3772526cd9feee2fa412c2b01933fea7ed13238a448d370d",
|
||||
"https://bcr.bazel.build/modules/bazel_lib/3.0.0/source.json": "895f21909c6fba01d7c17914bb6c8e135982275a1b18cdaa4e62272217ef1751",
|
||||
"https://bcr.bazel.build/modules/bazel_lib/3.2.0/MODULE.bazel": "39b50d94b9be6bda507862254e20c263f9b950e3160112348d10a938be9ce2c2",
|
||||
"https://bcr.bazel.build/modules/bazel_lib/3.2.0/source.json": "a6f45a903134bebbf33a6166dd42b4c7ab45169de094b37a85f348ca41170a84",
|
||||
"https://bcr.bazel.build/modules/bazel_skylib/1.0.3/MODULE.bazel": "bcb0fd896384802d1ad283b4e4eb4d718eebd8cb820b0a2c3a347fb971afd9d8",
|
||||
"https://bcr.bazel.build/modules/bazel_skylib/1.1.1/MODULE.bazel": "1add3e7d93ff2e6998f9e118022c84d163917d912f5afafb3058e3d2f1545b5e",
|
||||
"https://bcr.bazel.build/modules/bazel_skylib/1.2.0/MODULE.bazel": "44fe84260e454ed94ad326352a698422dbe372b21a1ac9f3eab76eb531223686",
|
||||
@@ -221,8 +224,8 @@
|
||||
"https://bcr.bazel.build/modules/tar.bzl/0.6.0/MODULE.bazel": "a3584b4edcfafcabd9b0ef9819808f05b372957bbdff41601429d5fd0aac2e7c",
|
||||
"https://bcr.bazel.build/modules/tar.bzl/0.6.0/source.json": "4a620381df075a16cb3a7ed57bd1d05f7480222394c64a20fa51bdb636fda658",
|
||||
"https://bcr.bazel.build/modules/toolchains_llvm_bootstrapped/0.5.2/MODULE.bazel": "f7c822cea99caef928d7cbe695498096e53c4b2c0ea45997e9a64bf6b77b43b0",
|
||||
"https://bcr.bazel.build/modules/toolchains_llvm_bootstrapped/0.5.3/MODULE.bazel": "902ab5c2bc9eff04df45d17aac3c5c642afaad2d4569dcfdc86d0256f68f18e0",
|
||||
"https://bcr.bazel.build/modules/toolchains_llvm_bootstrapped/0.5.3/source.json": "8c03ccd25188ecf8baf4a97a8b42c507ff592d917bcbc06333b9c82d18c7ef13",
|
||||
"https://bcr.bazel.build/modules/toolchains_llvm_bootstrapped/0.5.6/MODULE.bazel": "7298112608aefec21ea8bfbe325b14472222a0243c3dabcd0287eba418791f35",
|
||||
"https://bcr.bazel.build/modules/toolchains_llvm_bootstrapped/0.5.6/source.json": "c2a30627cfe15e4deeba63a75d1894324444ae0276eb17ac0161ba5c220c1cb2",
|
||||
"https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43",
|
||||
"https://bcr.bazel.build/modules/with_cfg.bzl/0.12.0/MODULE.bazel": "b573395fe63aef4299ba095173e2f62ccfee5ad9bbf7acaa95dba73af9fc2b38",
|
||||
"https://bcr.bazel.build/modules/with_cfg.bzl/0.12.0/source.json": "3f3fbaeafecaf629877ad152a2c9def21f8d330d91aa94c5dc75bbb98c10b8b8",
|
||||
@@ -1529,16 +1532,16 @@
|
||||
},
|
||||
"@@rules_rs+//rs/experimental/toolchains:module_extension.bzl%toolchains": {
|
||||
"cargo-1.93.0-aarch64-apple-darwin.tar.xz": "6443909350322ad07f09bb5edfd9ff29268e6fe88c7d78bfba7a5e254248dc25",
|
||||
"cargo-1.93.0-aarch64-pc-windows-gnullvm.tar.xz": "387832b989c8eb96c9ebd66402a87962167633bc6d91a49ffb8c7903c45f3476",
|
||||
"cargo-1.93.0-aarch64-pc-windows-msvc.tar.xz": "155bff7a16aa7054e7ed7c3a82e362d4b302b3882d751b823e06ff63ae3f103d",
|
||||
"cargo-1.93.0-aarch64-unknown-linux-gnu.tar.xz": "5998940b8b97286bb67facb1a85535eeb3d4d7a61e36a85e386e5c0c5cfe5266",
|
||||
"cargo-1.93.0-x86_64-apple-darwin.tar.xz": "95a47c5ed797c35419908f04188d8b7de09946e71073c4b72632b16f5b10dfae",
|
||||
"cargo-1.93.0-x86_64-pc-windows-gnullvm.tar.xz": "f19766837559f90476508140cb95cc708220012ec00a854fa9f99187b1f246b6",
|
||||
"cargo-1.93.0-x86_64-pc-windows-msvc.tar.xz": "e59c5e2baa9ec17261f2cda6676ebf7b68b21a860e3f7451c4d964728951da75",
|
||||
"cargo-1.93.0-x86_64-unknown-linux-gnu.tar.xz": "c23de3ae709ff33eed5e4ae59d1f9bcd75fa4dbaa9fb92f7b06bfb534b8db880",
|
||||
"clippy-1.93.0-aarch64-apple-darwin.tar.xz": "0b6e943a8d12be0e68575acf59c9ea102daf795055fcbbf862b0bfd35ec40039",
|
||||
"clippy-1.93.0-aarch64-pc-windows-gnullvm.tar.xz": "296949f49be2de77ce9d1c5023a5f0a58e28e329eec03642a3da0e175a67beeb",
|
||||
"clippy-1.93.0-aarch64-pc-windows-msvc.tar.xz": "07bcf2edb88cdf5ead2f02e4a8493e9b0ef935a31253fac6f9f3378d8023f113",
|
||||
"clippy-1.93.0-aarch64-unknown-linux-gnu.tar.xz": "872ae6d68d625946d281b91d928332e6b74f6ab269b6af842338df4338805a60",
|
||||
"clippy-1.93.0-x86_64-apple-darwin.tar.xz": "e6d0b1afb9607c14a1172d09ee194a032bbb3e48af913d55c5a473e0559eddde",
|
||||
"clippy-1.93.0-x86_64-pc-windows-gnullvm.tar.xz": "b6f1f7264ed6943c59dedfb9531fbadcc3c0fcf273c940a63d58898b14a1060f",
|
||||
"clippy-1.93.0-x86_64-pc-windows-msvc.tar.xz": "25fb103390bf392980b4689ac09b2ec2ab4beefb7022a983215b613ad05eab57",
|
||||
"clippy-1.93.0-x86_64-unknown-linux-gnu.tar.xz": "793108977514b15c0f45ade28ae35c58b05370cb0f22e89bd98fdfa61eabf55d",
|
||||
"rust-std-1.93.0-aarch64-apple-darwin.tar.xz": "8603c63715349636ed85b4fe716c4e827a727918c840e54aff5b243cedadf19b",
|
||||
"rust-std-1.93.0-aarch64-apple-ios-macabi.tar.xz": "24d47e615ce101869ff452a572a6b77ed7cf70f2454d0b50892ac849e8c7ac4d",
|
||||
@@ -1606,16 +1609,16 @@
|
||||
"rust-std-1.93.0-x86_64-unknown-none.tar.xz": "01dcca7ae4b7e82fbfa399adb5e160afaa13143e5a17e1e0737c38cf07365fb3",
|
||||
"rust-std-1.93.0-x86_64-unknown-uefi.tar.xz": "ec4e439d9485ce752b56999e8e41ed82373fc833a005cf2531c6f7ef7e785392",
|
||||
"rustc-1.93.0-aarch64-apple-darwin.tar.xz": "092be03c02b44c405dab1232541c84f32b2d9e8295747568c3d531dd137221dc",
|
||||
"rustc-1.93.0-aarch64-pc-windows-gnullvm.tar.xz": "d3bc0cdaf157e20b1f23e510b5e3c4c6e9117d08f5284c04dee60aecff1bc851",
|
||||
"rustc-1.93.0-aarch64-pc-windows-msvc.tar.xz": "a3ac1a8e411de8470f71b366f89d187718c431526912b181692ed0a18c56c7ad",
|
||||
"rustc-1.93.0-aarch64-unknown-linux-gnu.tar.xz": "1a9045695892ec08d8e9751bf7cf7db71fe27a6202dd12ce13aca48d0602dbde",
|
||||
"rustc-1.93.0-x86_64-apple-darwin.tar.xz": "594bb293f0a4f444656cf8dec2149fcb979c606260efee9e09bcf8c9c6ed6ae7",
|
||||
"rustc-1.93.0-x86_64-pc-windows-gnullvm.tar.xz": "0cdaa8de66f5ce21d1ea73917efc5c64f408bda49f678ddde19465ced9d5ec63",
|
||||
"rustc-1.93.0-x86_64-pc-windows-msvc.tar.xz": "fa17677eee0d83eb055b309953184bf87ba634923d8897f860cda65d55c6e350",
|
||||
"rustc-1.93.0-x86_64-unknown-linux-gnu.tar.xz": "00c6e6740ea6a795e33568cd7514855d58408a1180cd820284a7bbf7c46af715",
|
||||
"rustfmt-1.93.0-aarch64-apple-darwin.tar.xz": "0dd1faedf0768ef362f4aae4424b34e8266f2b9cf5e76ea4fcaf780220b363a0",
|
||||
"rustfmt-1.93.0-aarch64-pc-windows-gnullvm.tar.xz": "5888827e7fbd7d59930870b4856fce8d6d8fca5e02f6535f8ae3d7ad0ccf2d4a",
|
||||
"rustfmt-1.93.0-aarch64-pc-windows-msvc.tar.xz": "24eed108489567133bbfe40c8eacda1567be55fae4c526911b39eb33eb27a6cb",
|
||||
"rustfmt-1.93.0-aarch64-unknown-linux-gnu.tar.xz": "92e1acb45ae642136258b4dabb39302af2d53c83e56ebd5858bc969f9e5c141a",
|
||||
"rustfmt-1.93.0-x86_64-apple-darwin.tar.xz": "c8453b4c5758eb39423042ffa9c23ed6128cbed2b15b581e5e1192c9cc0b1d4e",
|
||||
"rustfmt-1.93.0-x86_64-pc-windows-gnullvm.tar.xz": "47167e9e78db9be4503a060dee02f4df2cda252da32175dbf44331f965a747b9",
|
||||
"rustfmt-1.93.0-x86_64-pc-windows-msvc.tar.xz": "5becc7c2dba4b9ab5199012cad30829235a7f7fb5d85a238697e8f0e44cbd9af",
|
||||
"rustfmt-1.93.0-x86_64-unknown-linux-gnu.tar.xz": "7f81f6c17d11a7fda5b4e1b111942fb3b23d30dcec767e13e340ebfb762a5e33"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+768
-343
File diff suppressed because it is too large
Load Diff
+4
-2
@@ -17,6 +17,7 @@ members = [
|
||||
"cli",
|
||||
"config",
|
||||
"shell-command",
|
||||
"shell-escalation",
|
||||
"skills",
|
||||
"core",
|
||||
"hooks",
|
||||
@@ -113,6 +114,7 @@ codex-responses-api-proxy = { path = "responses-api-proxy" }
|
||||
codex-rmcp-client = { path = "rmcp-client" }
|
||||
codex-secrets = { path = "secrets" }
|
||||
codex-shell-command = { path = "shell-command" }
|
||||
codex-shell-escalation = { path = "shell-escalation" }
|
||||
codex-skills = { path = "skills" }
|
||||
codex-state = { path = "state" }
|
||||
codex-stdio-to-uds = { path = "stdio-to-uds" }
|
||||
@@ -188,7 +190,7 @@ itertools = "0.14.0"
|
||||
keyring = { version = "3.6", default-features = false }
|
||||
landlock = "0.4.4"
|
||||
lazy_static = "1"
|
||||
libc = "0.2.177"
|
||||
libc = "0.2.182"
|
||||
log = "0.4"
|
||||
lru = "0.16.3"
|
||||
maplit = "1.0.2"
|
||||
@@ -204,7 +206,7 @@ opentelemetry-otlp = "0.31.0"
|
||||
opentelemetry-semantic-conventions = "0.31.0"
|
||||
opentelemetry_sdk = "0.31.0"
|
||||
os_info = "3.12.0"
|
||||
owo-colors = "4.2.0"
|
||||
owo-colors = "4.3.0"
|
||||
path-absolutize = "3.1.1"
|
||||
pathdiff = "0.2"
|
||||
portable-pty = "0.9.0"
|
||||
|
||||
@@ -23,10 +23,12 @@ struct AppServerArgs {
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
if codex_core::maybe_run_zsh_exec_wrapper_mode()? {
|
||||
return Ok(());
|
||||
}
|
||||
arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move {
|
||||
// Run wrapper mode only after arg0 dispatch so `codex-linux-sandbox`
|
||||
// invocations don't get misclassified as zsh exec-wrapper calls.
|
||||
if codex_core::maybe_run_zsh_exec_wrapper_mode()? {
|
||||
return Ok(());
|
||||
}
|
||||
let args = AppServerArgs::parse();
|
||||
let managed_config_path = managed_config_path_from_debug_env();
|
||||
let loader_overrides = LoaderOverrides {
|
||||
|
||||
@@ -49,6 +49,7 @@ use codex_core::default_client::set_default_originator;
|
||||
use codex_feedback::CodexFeedback;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use futures::FutureExt;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
@@ -386,8 +387,12 @@ impl MessageProcessor {
|
||||
.await;
|
||||
}
|
||||
other => {
|
||||
// Box the delegated future so this wrapper's async state machine does not
|
||||
// inline the full `CodexMessageProcessor::process_request` future, which
|
||||
// can otherwise push worker-thread stack usage over the edge.
|
||||
self.codex_message_processor
|
||||
.process_request(connection_id, other)
|
||||
.boxed()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,18 +2,15 @@
|
||||
//
|
||||
// Running these tests with the patched zsh fork:
|
||||
//
|
||||
// The suite uses `CODEX_TEST_ZSH_PATH` when set. Example:
|
||||
// CODEX_TEST_ZSH_PATH="$HOME/.local/codex-zsh-77045ef/bin/zsh" \
|
||||
// cargo test -p codex-app-server turn_start_zsh_fork -- --nocapture
|
||||
//
|
||||
// For a single test:
|
||||
// CODEX_TEST_ZSH_PATH="$HOME/.local/codex-zsh-77045ef/bin/zsh" \
|
||||
// cargo test -p codex-app-server turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2 -- --nocapture
|
||||
// The suite resolves the shared test-only zsh DotSlash file at
|
||||
// `exec-server/tests/suite/zsh` via DotSlash on first use, so `dotslash` and
|
||||
// network access are required the first time the artifact is fetched.
|
||||
|
||||
use anyhow::Result;
|
||||
use app_test_support::McpProcess;
|
||||
use app_test_support::create_final_assistant_message_sse_response;
|
||||
use app_test_support::create_mock_responses_server_sequence;
|
||||
use app_test_support::create_mock_responses_server_sequence_unchecked;
|
||||
use app_test_support::create_shell_command_sse_response;
|
||||
use app_test_support::to_response;
|
||||
use codex_app_server_protocol::CommandExecutionApprovalDecision;
|
||||
@@ -38,6 +35,7 @@ use core_test_support::responses;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::BTreeMap;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
@@ -57,7 +55,7 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> {
|
||||
let workspace = tmp.path().join("workspace");
|
||||
std::fs::create_dir(&workspace)?;
|
||||
|
||||
let Some(zsh_path) = find_test_zsh_path() else {
|
||||
let Some(zsh_path) = find_test_zsh_path()? else {
|
||||
eprintln!("skipping zsh fork test: no zsh executable found");
|
||||
return Ok(());
|
||||
};
|
||||
@@ -82,7 +80,7 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> {
|
||||
&zsh_path,
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new(&codex_home).await?;
|
||||
let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let start_id = mcp
|
||||
@@ -167,7 +165,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_decline_v2() -> Result<()> {
|
||||
let workspace = tmp.path().join("workspace");
|
||||
std::fs::create_dir(&workspace)?;
|
||||
|
||||
let Some(zsh_path) = find_test_zsh_path() else {
|
||||
let Some(zsh_path) = find_test_zsh_path()? else {
|
||||
eprintln!("skipping zsh fork decline test: no zsh executable found");
|
||||
return Ok(());
|
||||
};
|
||||
@@ -199,7 +197,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_decline_v2() -> Result<()> {
|
||||
&zsh_path,
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new(&codex_home).await?;
|
||||
let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let start_id = mcp
|
||||
@@ -303,7 +301,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_cancel_v2() -> Result<()> {
|
||||
let workspace = tmp.path().join("workspace");
|
||||
std::fs::create_dir(&workspace)?;
|
||||
|
||||
let Some(zsh_path) = find_test_zsh_path() else {
|
||||
let Some(zsh_path) = find_test_zsh_path()? else {
|
||||
eprintln!("skipping zsh fork cancel test: no zsh executable found");
|
||||
return Ok(());
|
||||
};
|
||||
@@ -332,7 +330,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_cancel_v2() -> Result<()> {
|
||||
&zsh_path,
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new(&codex_home).await?;
|
||||
let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let start_id = mcp
|
||||
@@ -434,7 +432,7 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
|
||||
let workspace = tmp.path().join("workspace");
|
||||
std::fs::create_dir(&workspace)?;
|
||||
|
||||
let Some(zsh_path) = find_test_zsh_path() else {
|
||||
let Some(zsh_path) = find_test_zsh_path()? else {
|
||||
eprintln!("skipping zsh fork subcommand decline test: no zsh executable found");
|
||||
return Ok(());
|
||||
};
|
||||
@@ -446,6 +444,29 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
|
||||
return Ok(());
|
||||
}
|
||||
eprintln!("using zsh path for zsh-fork test: {}", zsh_path.display());
|
||||
let zsh_path_for_config = {
|
||||
// App-server config accepts only a zsh path, not extra argv. Use a
|
||||
// wrapper so this test can force `-df` and downgrade `-lc` to `-c`
|
||||
// to avoid rc/login-shell startup noise.
|
||||
let path = workspace.join("zsh-no-rc");
|
||||
std::fs::write(
|
||||
&path,
|
||||
format!(
|
||||
r#"#!/bin/sh
|
||||
if [ "$1" = "-lc" ]; then
|
||||
shift
|
||||
set -- -c "$@"
|
||||
fi
|
||||
exec "{}" -df "$@"
|
||||
"#,
|
||||
zsh_path.display()
|
||||
),
|
||||
)?;
|
||||
let mut permissions = std::fs::metadata(&path)?.permissions();
|
||||
permissions.set_mode(0o755);
|
||||
std::fs::set_permissions(&path, permissions)?;
|
||||
path
|
||||
};
|
||||
|
||||
let tool_call_arguments = serde_json::to_string(&serde_json::json!({
|
||||
"command": "/usr/bin/true && /usr/bin/true",
|
||||
@@ -461,7 +482,16 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
|
||||
),
|
||||
responses::ev_completed("resp-1"),
|
||||
]);
|
||||
let server = create_mock_responses_server_sequence(vec![response]).await;
|
||||
let no_op_response = responses::sse(vec![
|
||||
responses::ev_response_created("resp-2"),
|
||||
responses::ev_completed("resp-2"),
|
||||
]);
|
||||
// Linux CI has occasionally issued a second `/responses` POST after the
|
||||
// subcommand-decline flow. This test is about approval/decline behavior in
|
||||
// the zsh fork, not exact model request count, so allow an extra request
|
||||
// and return a harmless no-op response if it arrives.
|
||||
let server =
|
||||
create_mock_responses_server_sequence_unchecked(vec![response, no_op_response]).await;
|
||||
create_config_toml(
|
||||
&codex_home,
|
||||
&server.uri(),
|
||||
@@ -471,10 +501,10 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
|
||||
(Feature::UnifiedExec, false),
|
||||
(Feature::ShellSnapshot, false),
|
||||
]),
|
||||
&zsh_path,
|
||||
&zsh_path_for_config,
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new(&codex_home).await?;
|
||||
let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let start_id = mcp
|
||||
@@ -500,8 +530,16 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
|
||||
}],
|
||||
cwd: Some(workspace.clone()),
|
||||
approval_policy: Some(codex_app_server_protocol::AskForApproval::OnRequest),
|
||||
sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::ReadOnly {
|
||||
access: codex_app_server_protocol::ReadOnlyAccess::FullAccess,
|
||||
sandbox_policy: Some(if cfg!(target_os = "linux") {
|
||||
// The zsh exec-bridge wrapper uses a Unix socket back to the parent
|
||||
// process. Linux restricted sandbox seccomp denies connect(2), so use
|
||||
// full access here; this test is validating zsh approval/decline
|
||||
// behavior, not Linux sandboxing.
|
||||
codex_app_server_protocol::SandboxPolicy::DangerFullAccess
|
||||
} else {
|
||||
codex_app_server_protocol::SandboxPolicy::ReadOnly {
|
||||
access: codex_app_server_protocol::ReadOnlyAccess::FullAccess,
|
||||
}
|
||||
}),
|
||||
model: Some("mock-model".to_string()),
|
||||
effort: Some(codex_protocol::openai_models::ReasoningEffort::Medium),
|
||||
@@ -517,10 +555,13 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
|
||||
let TurnStartResponse { turn } = to_response::<TurnStartResponse>(turn_resp)?;
|
||||
|
||||
let mut approval_ids = Vec::new();
|
||||
for decision in [
|
||||
let mut saw_parent_approval = false;
|
||||
let target_decisions = [
|
||||
CommandExecutionApprovalDecision::Accept,
|
||||
CommandExecutionApprovalDecision::Cancel,
|
||||
] {
|
||||
];
|
||||
let mut target_decision_index = 0;
|
||||
while target_decision_index < target_decisions.len() {
|
||||
let server_req = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_request_message(),
|
||||
@@ -531,13 +572,40 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
|
||||
panic!("expected CommandExecutionRequestApproval request");
|
||||
};
|
||||
assert_eq!(params.item_id, "call-zsh-fork-subcommand-decline");
|
||||
approval_ids.push(
|
||||
params
|
||||
.approval_id
|
||||
.clone()
|
||||
.expect("approval_id must be present for zsh subcommand approvals"),
|
||||
);
|
||||
assert_eq!(params.thread_id, thread.id);
|
||||
let is_target_subcommand = params.command.as_deref() == Some("/usr/bin/true");
|
||||
if is_target_subcommand {
|
||||
approval_ids.push(
|
||||
params
|
||||
.approval_id
|
||||
.clone()
|
||||
.expect("approval_id must be present for zsh subcommand approvals"),
|
||||
);
|
||||
}
|
||||
let decision = if is_target_subcommand {
|
||||
let decision = target_decisions[target_decision_index].clone();
|
||||
target_decision_index += 1;
|
||||
decision
|
||||
} else {
|
||||
let command = params
|
||||
.command
|
||||
.as_deref()
|
||||
.expect("approval command should be present");
|
||||
assert!(
|
||||
!saw_parent_approval,
|
||||
"unexpected extra non-target approval: {command}"
|
||||
);
|
||||
assert!(
|
||||
command.contains("zsh-no-rc"),
|
||||
"expected parent zsh wrapper approval, got: {command}"
|
||||
);
|
||||
assert!(
|
||||
command.contains("/usr/bin/true && /usr/bin/true"),
|
||||
"expected tool command in parent approval, got: {command}"
|
||||
);
|
||||
saw_parent_approval = true;
|
||||
CommandExecutionApprovalDecision::Accept
|
||||
};
|
||||
mcp.send_response(
|
||||
request_id,
|
||||
serde_json::to_value(CommandExecutionRequestApprovalResponse { decision })?,
|
||||
@@ -545,6 +613,8 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
|
||||
.await?;
|
||||
}
|
||||
|
||||
assert_eq!(approval_ids.len(), 2);
|
||||
assert_ne!(approval_ids[0], approval_ids[1]);
|
||||
let parent_completed_command_execution = timeout(DEFAULT_READ_TIMEOUT, async {
|
||||
loop {
|
||||
let completed_notif = mcp
|
||||
@@ -563,32 +633,61 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
|
||||
}
|
||||
}
|
||||
})
|
||||
.await??;
|
||||
.await;
|
||||
|
||||
let ThreadItem::CommandExecution {
|
||||
id,
|
||||
status,
|
||||
aggregated_output,
|
||||
..
|
||||
} = parent_completed_command_execution
|
||||
else {
|
||||
unreachable!("loop ensures we break on parent command execution item");
|
||||
};
|
||||
assert_eq!(id, "call-zsh-fork-subcommand-decline");
|
||||
assert_eq!(status, CommandExecutionStatus::Declined);
|
||||
assert!(
|
||||
aggregated_output.is_none()
|
||||
|| aggregated_output == Some("exec command rejected by user".to_string())
|
||||
);
|
||||
assert_eq!(approval_ids.len(), 2);
|
||||
assert_ne!(approval_ids[0], approval_ids[1]);
|
||||
match parent_completed_command_execution {
|
||||
Ok(Ok(parent_completed_command_execution)) => {
|
||||
let ThreadItem::CommandExecution {
|
||||
id,
|
||||
status,
|
||||
aggregated_output,
|
||||
..
|
||||
} = parent_completed_command_execution
|
||||
else {
|
||||
unreachable!("loop ensures we break on parent command execution item");
|
||||
};
|
||||
assert_eq!(id, "call-zsh-fork-subcommand-decline");
|
||||
assert_eq!(status, CommandExecutionStatus::Declined);
|
||||
assert!(
|
||||
aggregated_output.is_none()
|
||||
|| aggregated_output == Some("exec command rejected by user".to_string())
|
||||
);
|
||||
|
||||
mcp.interrupt_turn_and_wait_for_aborted(thread.id, turn.id, DEFAULT_READ_TIMEOUT)
|
||||
.await?;
|
||||
mcp.interrupt_turn_and_wait_for_aborted(
|
||||
thread.id.clone(),
|
||||
turn.id.clone(),
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(Err(error)) => return Err(error),
|
||||
Err(_) => {
|
||||
// Some zsh builds abort the turn immediately after the rejected
|
||||
// subcommand without emitting a parent `item/completed`.
|
||||
let completed_notif = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
let completed: TurnCompletedNotification = serde_json::from_value(
|
||||
completed_notif
|
||||
.params
|
||||
.expect("turn/completed params must be present"),
|
||||
)?;
|
||||
assert_eq!(completed.thread_id, thread.id);
|
||||
assert_eq!(completed.turn.id, turn.id);
|
||||
assert_eq!(completed.turn.status, TurnStatus::Interrupted);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_zsh_test_mcp_process(codex_home: &Path, zdotdir: &Path) -> Result<McpProcess> {
|
||||
let zdotdir = zdotdir.to_string_lossy().into_owned();
|
||||
McpProcess::new_with_env(codex_home, &[("ZDOTDIR", Some(zdotdir.as_str()))]).await
|
||||
}
|
||||
|
||||
fn create_config_toml(
|
||||
codex_home: &Path,
|
||||
server_uri: &str,
|
||||
@@ -640,36 +739,24 @@ stream_max_retries = 0
|
||||
)
|
||||
}
|
||||
|
||||
fn find_test_zsh_path() -> Option<std::path::PathBuf> {
|
||||
if let Some(path) = std::env::var_os("CODEX_TEST_ZSH_PATH") {
|
||||
let path = std::path::PathBuf::from(path);
|
||||
if path.is_file() {
|
||||
return Some(path);
|
||||
}
|
||||
panic!(
|
||||
"CODEX_TEST_ZSH_PATH is set but is not a file: {}",
|
||||
path.display()
|
||||
fn find_test_zsh_path() -> Result<Option<std::path::PathBuf>> {
|
||||
let repo_root = codex_utils_cargo_bin::repo_root()?;
|
||||
let dotslash_zsh = repo_root.join("codex-rs/exec-server/tests/suite/zsh");
|
||||
if !dotslash_zsh.is_file() {
|
||||
eprintln!(
|
||||
"skipping zsh fork test: shared zsh DotSlash file not found at {}",
|
||||
dotslash_zsh.display()
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
for candidate in ["/bin/zsh", "/usr/bin/zsh"] {
|
||||
let path = Path::new(candidate);
|
||||
if path.is_file() {
|
||||
return Some(path.to_path_buf());
|
||||
match core_test_support::fetch_dotslash_file(&dotslash_zsh, None) {
|
||||
Ok(path) => return Ok(Some(path)),
|
||||
Err(error) => {
|
||||
eprintln!("failed to fetch vendored zsh via dotslash: {error:#}");
|
||||
}
|
||||
}
|
||||
|
||||
let shell = std::env::var_os("SHELL")?;
|
||||
let shell_path = std::path::PathBuf::from(shell);
|
||||
if shell_path
|
||||
.file_name()
|
||||
.is_some_and(|file_name| file_name == "zsh")
|
||||
&& shell_path.is_file()
|
||||
{
|
||||
return Some(shell_path);
|
||||
}
|
||||
|
||||
None
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn supports_exec_wrapper_intercept(zsh_path: &Path) -> bool {
|
||||
|
||||
@@ -543,10 +543,12 @@ fn stage_str(stage: codex_core::features::Stage) -> &'static str {
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
if codex_core::maybe_run_zsh_exec_wrapper_mode()? {
|
||||
return Ok(());
|
||||
}
|
||||
arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move {
|
||||
// Run wrapper mode only after arg0 dispatch so `codex-linux-sandbox`
|
||||
// invocations don't get misclassified as zsh exec-wrapper calls.
|
||||
if codex_core::maybe_run_zsh_exec_wrapper_mode()? {
|
||||
return Ok(());
|
||||
}
|
||||
cli_main(codex_linux_sandbox_exe).await?;
|
||||
Ok(())
|
||||
})
|
||||
@@ -1110,6 +1112,34 @@ mod tests {
|
||||
assert_eq!(args.prompt.as_deref(), Some("2+2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_resume_accepts_output_last_message_flag_after_subcommand() {
|
||||
let cli = MultitoolCli::try_parse_from([
|
||||
"codex",
|
||||
"exec",
|
||||
"resume",
|
||||
"session-123",
|
||||
"-o",
|
||||
"/tmp/resume-output.md",
|
||||
"re-review",
|
||||
])
|
||||
.expect("parse should succeed");
|
||||
|
||||
let Some(Subcommand::Exec(exec)) = cli.subcommand else {
|
||||
panic!("expected exec subcommand");
|
||||
};
|
||||
let Some(codex_exec::Command::Resume(args)) = exec.command else {
|
||||
panic!("expected exec resume");
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
exec.last_message_file,
|
||||
Some(std::path::PathBuf::from("/tmp/resume-output.md"))
|
||||
);
|
||||
assert_eq!(args.session_id.as_deref(), Some("session-123"));
|
||||
assert_eq!(args.prompt.as_deref(), Some("re-review"));
|
||||
}
|
||||
|
||||
fn app_server_from_args(args: &[&str]) -> AppServerCommand {
|
||||
let cli = MultitoolCli::try_parse_from(args).expect("parse");
|
||||
let Subcommand::AppServer(app_server) = cli.subcommand.expect("app-server present") else {
|
||||
|
||||
@@ -49,10 +49,12 @@ async fn features_enable_under_development_feature_prints_warning() -> Result<()
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
let mut cmd = codex_command(codex_home.path())?;
|
||||
cmd.args(["features", "enable", "sqlite"])
|
||||
cmd.args(["features", "enable", "runtime_metrics"])
|
||||
.assert()
|
||||
.success()
|
||||
.stderr(contains("Under-development features enabled: sqlite."));
|
||||
.stderr(contains(
|
||||
"Under-development features enabled: runtime_metrics.",
|
||||
));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -349,6 +349,9 @@
|
||||
"js_repl_tools_only": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"memories": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"memory_tool": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -412,6 +415,9 @@
|
||||
"use_linux_sandbox_bwrap": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"voice_transcription": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"web_search": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -1627,6 +1633,9 @@
|
||||
"js_repl_tools_only": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"memories": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"memory_tool": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -1690,6 +1699,9 @@
|
||||
"use_linux_sandbox_bwrap": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"voice_transcription": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"web_search": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
+10
-11
@@ -1,36 +1,35 @@
|
||||
background_terminal_max_timeout = 3600000
|
||||
model_reasoning_effort = "low"
|
||||
developer_instructions="""You are a waiting agent.
|
||||
You're name is Superman.
|
||||
Your role is to monitor the execution of a specific command or task and report its status only when it is finished.
|
||||
developer_instructions="""You are an awaiter.
|
||||
Your role is to await the completion of a specific command or task and report its status only when it is finished.
|
||||
|
||||
Behavior rules:
|
||||
|
||||
1. When given a command or task identifier, you must:
|
||||
- Execute or monitor it using the appropriate tool
|
||||
- Continue waiting until the task reaches a terminal state.
|
||||
- Execute or await it using the appropriate tool
|
||||
- Continue awaiting until the task reaches a terminal state.
|
||||
|
||||
2. You must NOT:
|
||||
- Modify the task.
|
||||
- Interpret or optimize the task.
|
||||
- Perform unrelated actions.
|
||||
- Stop waiting unless explicitly instructed.
|
||||
- Stop awaiting unless explicitly instructed.
|
||||
|
||||
3. Waiting behavior:
|
||||
3. Awaiting behavior:
|
||||
- If the task is still running, continue polling using tool calls.
|
||||
- Use repeated tool calls if necessary.
|
||||
- Do not hallucinate completion.
|
||||
- Use long timeouts when waiting for something. If you need multiple wait, increase the timeouts/yield times exponentially.
|
||||
- Use long timeouts when awaiting for something. If you need multiple awaits, increase the timeouts/yield times exponentially.
|
||||
|
||||
4. If asked for status:
|
||||
- Return the current known status.
|
||||
- Immediately resume waiting afterward.
|
||||
- Immediately resume awaiting afterward.
|
||||
|
||||
5. Termination:
|
||||
- Only exit waiting when:
|
||||
- Only exit awaiting when:
|
||||
- The task completes successfully, OR
|
||||
- The task fails, OR
|
||||
- You receive an explicit stop instruction.
|
||||
|
||||
You must behave deterministically and conservatively.
|
||||
"""
|
||||
"""
|
||||
@@ -118,6 +118,9 @@ impl Guards {
|
||||
} else {
|
||||
active_agents.used_agent_nicknames.clear();
|
||||
active_agents.nickname_reset_count += 1;
|
||||
if let Some(metrics) = codex_otel::metrics::global() {
|
||||
let _ = metrics.counter("codex.multi_agent.nickname_pool_reset", 1, &[]);
|
||||
}
|
||||
names.choose(&mut rand::rng())?.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ use std::path::Path;
|
||||
use std::sync::LazyLock;
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
const DEFAULT_ROLE_NAME: &str = "default";
|
||||
pub const DEFAULT_ROLE_NAME: &str = "default";
|
||||
const AGENT_TYPE_UNAVAILABLE_ERROR: &str = "agent type is currently not available";
|
||||
|
||||
/// Applies a role config layer to a mutable config and preserves unspecified keys.
|
||||
@@ -187,16 +187,16 @@ Rules:
|
||||
}
|
||||
),
|
||||
(
|
||||
"monitor".to_string(),
|
||||
"awaiter".to_string(),
|
||||
AgentRoleConfig {
|
||||
description: Some(r#"Use a `monitor` agent EVERY TIME you must run a command that might take some time.
|
||||
description: Some(r#"Use an `awaiter` agent EVERY TIME you must run a command that might take some time.
|
||||
This includes, but not only:
|
||||
* testing
|
||||
* monitoring of a long running process
|
||||
* explicit ask to wait for something
|
||||
|
||||
When YOU wait for the `monitor` agent to be done, use the largest possible timeout."#.to_string()),
|
||||
config_file: Some("monitor.toml".to_string().parse().unwrap_or_default()),
|
||||
When YOU wait for the `awaiter` agent to be done, use the largest possible timeout."#.to_string()),
|
||||
config_file: Some("awaiter.toml".to_string().parse().unwrap_or_default()),
|
||||
}
|
||||
)
|
||||
])
|
||||
@@ -207,10 +207,10 @@ When YOU wait for the `monitor` agent to be done, use the largest possible timeo
|
||||
/// Resolves a built-in role `config_file` path to embedded content.
|
||||
pub(super) fn config_file_contents(path: &Path) -> Option<&'static str> {
|
||||
const EXPLORER: &str = include_str!("builtins/explorer.toml");
|
||||
const MONITOR: &str = include_str!("builtins/monitor.toml");
|
||||
const AWAITER: &str = include_str!("builtins/awaiter.toml");
|
||||
match path.to_str()? {
|
||||
"explorer.toml" => Some(EXPLORER),
|
||||
"monitor.toml" => Some(MONITOR),
|
||||
"awaiter.toml" => Some(AWAITER),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,9 +134,12 @@ pub enum Feature {
|
||||
/// Steer feature flag - when enabled, Enter submits immediately instead of queuing.
|
||||
Steer,
|
||||
/// Enable collaboration modes (Plan, Default).
|
||||
/// Kept for config backward compatibility; behavior is always collaboration-modes-enabled.
|
||||
CollaborationModes,
|
||||
/// Enable personality selection in the TUI.
|
||||
Personality,
|
||||
/// Enable voice transcription in the TUI composer.
|
||||
VoiceTranscription,
|
||||
/// Prevent idle system sleep while a turn is actively running.
|
||||
PreventIdleSleep,
|
||||
/// Use the Responses API WebSocket transport for OpenAI by default.
|
||||
@@ -494,12 +497,12 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
FeatureSpec {
|
||||
id: Feature::Sqlite,
|
||||
key: "sqlite",
|
||||
stage: Stage::UnderDevelopment,
|
||||
default_enabled: false,
|
||||
stage: Stage::Stable,
|
||||
default_enabled: true,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::MemoryTool,
|
||||
key: "memory_tool",
|
||||
key: "memories",
|
||||
stage: Stage::UnderDevelopment,
|
||||
default_enabled: false,
|
||||
},
|
||||
@@ -617,7 +620,7 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
FeatureSpec {
|
||||
id: Feature::CollaborationModes,
|
||||
key: "collaboration_modes",
|
||||
stage: Stage::Stable,
|
||||
stage: Stage::Removed,
|
||||
default_enabled: true,
|
||||
},
|
||||
FeatureSpec {
|
||||
@@ -626,6 +629,12 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
stage: Stage::Stable,
|
||||
default_enabled: true,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::VoiceTranscription,
|
||||
key: "voice_transcription",
|
||||
stage: Stage::UnderDevelopment,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::PreventIdleSleep,
|
||||
key: "prevent_idle_sleep",
|
||||
@@ -728,10 +737,9 @@ mod tests {
|
||||
fn default_enabled_features_are_stable() {
|
||||
for spec in FEATURES {
|
||||
if spec.default_enabled {
|
||||
assert_eq!(
|
||||
spec.stage,
|
||||
Stage::Stable,
|
||||
"feature `{}` is enabled by default but is not stable ({:?})",
|
||||
assert!(
|
||||
matches!(spec.stage, Stage::Stable | Stage::Removed),
|
||||
"feature `{}` is enabled by default but is not stable/removed ({:?})",
|
||||
spec.key,
|
||||
spec.stage
|
||||
);
|
||||
|
||||
@@ -37,6 +37,10 @@ const ALIASES: &[Alias] = &[
|
||||
legacy_key: "collab",
|
||||
feature: Feature::Collab,
|
||||
},
|
||||
Alias {
|
||||
legacy_key: "memory_tool",
|
||||
feature: Feature::MemoryTool,
|
||||
},
|
||||
];
|
||||
|
||||
pub(crate) fn legacy_feature_keys() -> impl Iterator<Item = &'static str> {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Memories Pipeline (Core)
|
||||
|
||||
This module runs a startup memory pipeline for eligible sessions.
|
||||
|
||||
## When it runs
|
||||
|
||||
The pipeline is triggered when a root session starts, and only if:
|
||||
|
||||
- the session is not ephemeral
|
||||
- the memory feature is enabled
|
||||
- the session is not a sub-agent session
|
||||
- the state DB is available
|
||||
|
||||
It runs asynchronously in the background and executes two phases in order: Phase 1, then Phase 2.
|
||||
|
||||
## Phase 1: Rollout Extraction (per-thread)
|
||||
|
||||
Phase 1 finds recent eligible rollouts and extracts a structured memory from each one.
|
||||
|
||||
Eligible rollouts are selected from the state DB using startup claim rules. In practice this means
|
||||
the pipeline only considers rollouts that are:
|
||||
|
||||
- from allowed interactive session sources
|
||||
- within the configured age window
|
||||
- idle long enough (to avoid summarizing still-active/fresh rollouts)
|
||||
- not already owned by another in-flight phase-1 worker
|
||||
- within startup scan/claim limits (bounded work per startup)
|
||||
|
||||
What it does:
|
||||
|
||||
- claims a bounded set of rollout jobs from the state DB (startup claim)
|
||||
- filters rollout content down to memory-relevant response items
|
||||
- sends each rollout to a model (in parallel, with a concurrency cap)
|
||||
- expects structured output containing:
|
||||
- a detailed `raw_memory`
|
||||
- a compact `rollout_summary`
|
||||
- an optional `rollout_slug`
|
||||
- redacts secrets from the generated memory fields
|
||||
- stores successful outputs back into the state DB as stage-1 outputs
|
||||
|
||||
Concurrency / coordination:
|
||||
|
||||
- Phase 1 runs multiple extraction jobs in parallel (with a fixed concurrency cap) so startup memory generation can process several rollouts at once.
|
||||
- Each job is leased/claimed in the state DB before processing, which prevents duplicate work across concurrent workers/startups.
|
||||
- Failed jobs are marked with retry backoff, so they are retried later instead of hot-looping.
|
||||
|
||||
Job outcomes:
|
||||
|
||||
- `succeeded` (memory produced)
|
||||
- `succeeded_no_output` (valid run but nothing useful generated)
|
||||
- `failed` (with retry backoff/lease handling in DB)
|
||||
|
||||
Phase 1 is the stage that turns individual rollouts into DB-backed memory records.
|
||||
|
||||
## Phase 2: Global Consolidation
|
||||
|
||||
Phase 2 consolidates the latest stage-1 outputs into the filesystem memory artifacts and then runs a dedicated consolidation agent.
|
||||
|
||||
What it does:
|
||||
|
||||
- claims a single global phase-2 job (so only one consolidation runs at a time)
|
||||
- loads a bounded set of the most recent stage-1 outputs from the state DB (the per-rollout memories produced by Phase 1, used as the consolidation input set)
|
||||
- computes a completion watermark from the claimed watermark + newest input timestamps
|
||||
- syncs local memory artifacts under the memories root:
|
||||
- `raw_memories.md` (merged raw memories, latest first)
|
||||
- `rollout_summaries/` (one summary file per retained rollout)
|
||||
- prunes stale rollout summaries that are no longer retained
|
||||
- if there are no inputs, marks the job successful and exits
|
||||
|
||||
If there is input, it then:
|
||||
|
||||
- spawns an internal consolidation sub-agent
|
||||
- runs it with no approvals, no network, and local write access only
|
||||
- disables collab for that agent (to prevent recursive delegation)
|
||||
- watches the agent status and heartbeats the global job lease while it runs
|
||||
- marks the phase-2 job success/failure in the state DB when the agent finishes
|
||||
|
||||
Watermark behavior:
|
||||
|
||||
- The global phase-2 job claim includes an input watermark representing the latest input timestamp known when the job was claimed.
|
||||
- Phase 2 recomputes a `new_watermark` using the max of:
|
||||
- the claimed watermark
|
||||
- the newest `source_updated_at` timestamp in the stage-1 inputs it actually loaded
|
||||
- On success, Phase 2 stores that completion watermark in the DB.
|
||||
- This lets later phase-2 runs know whether new stage-1 data arrived since the last successful consolidation (dirty vs not dirty), while also avoiding moving the watermark backwards.
|
||||
|
||||
In practice, this phase is responsible for refreshing the on-disk memory workspace and producing/updating the higher-level consolidated memory outputs.
|
||||
|
||||
## Why it is split into two phases
|
||||
|
||||
- Phase 1 scales across many rollouts and produces normalized per-rollout memory records.
|
||||
- Phase 2 serializes global consolidation so the shared memory artifacts are updated safely and consistently.
|
||||
@@ -217,7 +217,8 @@ pub(crate) async fn handle_start(
|
||||
_ => None,
|
||||
};
|
||||
if let Some(text) = maybe_routed_text {
|
||||
sess_clone.route_realtime_text_input(text).await;
|
||||
let sess_for_routed_text = Arc::clone(&sess_clone);
|
||||
sess_for_routed_text.route_realtime_text_input(text).await;
|
||||
}
|
||||
sess_clone
|
||||
.send_event_raw(ev(EventMsg::RealtimeConversationRealtime(
|
||||
@@ -252,16 +253,25 @@ pub(crate) async fn handle_audio(
|
||||
}
|
||||
|
||||
fn realtime_text_from_conversation_item(item: &Value) -> Option<String> {
|
||||
if item.get("type").and_then(Value::as_str) != Some("message") {
|
||||
return None;
|
||||
match item.get("type").and_then(Value::as_str) {
|
||||
Some("message") => {
|
||||
if item.get("role").and_then(Value::as_str) != Some("assistant") {
|
||||
return None;
|
||||
}
|
||||
let content = item.get("content")?.as_array()?;
|
||||
let text = content
|
||||
.iter()
|
||||
.filter(|entry| entry.get("type").and_then(Value::as_str) == Some("text"))
|
||||
.filter_map(|entry| entry.get("text").and_then(Value::as_str))
|
||||
.collect::<String>();
|
||||
if text.is_empty() { None } else { Some(text) }
|
||||
}
|
||||
Some("spawn_transcript") => item
|
||||
.get("delta_user_transcript")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|text| (!text.is_empty()).then(|| text.to_string())),
|
||||
Some(_) | None => None,
|
||||
}
|
||||
let content = item.get("content")?.as_array()?;
|
||||
let text = content
|
||||
.iter()
|
||||
.filter(|entry| entry.get("type").and_then(Value::as_str) == Some("text"))
|
||||
.filter_map(|entry| entry.get("text").and_then(Value::as_str))
|
||||
.collect::<String>();
|
||||
if text.is_empty() { None } else { Some(text) }
|
||||
}
|
||||
|
||||
pub(crate) async fn handle_text(
|
||||
@@ -301,7 +311,6 @@ fn spawn_realtime_input_task(
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
text = text_rx.recv() => {
|
||||
match text {
|
||||
Ok(text) => {
|
||||
@@ -388,7 +397,7 @@ mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn extracts_text_from_message_items_ignoring_role() {
|
||||
fn extracts_text_from_assistant_message_items_only() {
|
||||
let assistant = json!({
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
@@ -404,16 +413,14 @@ mod tests {
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "world"}],
|
||||
});
|
||||
assert_eq!(
|
||||
realtime_text_from_conversation_item(&user),
|
||||
Some("world".to_string())
|
||||
);
|
||||
assert_eq!(realtime_text_from_conversation_item(&user), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_and_concatenates_text_entries_only() {
|
||||
let item = json!({
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "a"},
|
||||
{"type": "ignored", "text": "x"},
|
||||
@@ -436,8 +443,31 @@ mod tests {
|
||||
|
||||
let no_text = json!({
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "other", "value": 1}],
|
||||
});
|
||||
assert_eq!(realtime_text_from_conversation_item(&no_text), None);
|
||||
|
||||
let empty_spawn_transcript = json!({
|
||||
"type": "spawn_transcript",
|
||||
"delta_user_transcript": "",
|
||||
});
|
||||
assert_eq!(
|
||||
realtime_text_from_conversation_item(&empty_spawn_transcript),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_text_from_spawn_transcript_items() {
|
||||
let item = json!({
|
||||
"type": "spawn_transcript",
|
||||
"delta_user_transcript": "delegate from transcript",
|
||||
"backend_prompt_messages": [{"role": "user", "content": "delegate from transcript"}],
|
||||
});
|
||||
assert_eq!(
|
||||
realtime_text_from_conversation_item(&item),
|
||||
Some("delegate from transcript".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ use async_trait::async_trait;
|
||||
use codex_apply_patch::ApplyPatchAction;
|
||||
use codex_apply_patch::ApplyPatchFileChange;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct ApplyPatchHandler;
|
||||
|
||||
@@ -139,8 +140,8 @@ impl ToolHandler for ApplyPatchHandler {
|
||||
let mut orchestrator = ToolOrchestrator::new();
|
||||
let mut runtime = ApplyPatchRuntime::new();
|
||||
let tool_ctx = ToolCtx {
|
||||
session: session.as_ref(),
|
||||
turn: turn.as_ref(),
|
||||
session: session.clone(),
|
||||
turn: turn.clone(),
|
||||
call_id: call_id.clone(),
|
||||
tool_name: tool_name.to_string(),
|
||||
};
|
||||
@@ -149,7 +150,7 @@ impl ToolHandler for ApplyPatchHandler {
|
||||
&mut runtime,
|
||||
&req,
|
||||
&tool_ctx,
|
||||
&turn,
|
||||
turn.as_ref(),
|
||||
turn.approval_policy.value(),
|
||||
)
|
||||
.await
|
||||
@@ -193,8 +194,8 @@ pub(crate) async fn intercept_apply_patch(
|
||||
command: &[String],
|
||||
cwd: &Path,
|
||||
timeout_ms: Option<u64>,
|
||||
session: &Session,
|
||||
turn: &TurnContext,
|
||||
session: Arc<Session>,
|
||||
turn: Arc<TurnContext>,
|
||||
tracker: Option<&SharedTurnDiffTracker>,
|
||||
call_id: &str,
|
||||
tool_name: &str,
|
||||
@@ -203,11 +204,13 @@ pub(crate) async fn intercept_apply_patch(
|
||||
codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => {
|
||||
session
|
||||
.record_model_warning(
|
||||
format!("apply_patch was requested via {tool_name}. Use the apply_patch tool instead of exec_command."),
|
||||
turn,
|
||||
format!(
|
||||
"apply_patch was requested via {tool_name}. Use the apply_patch tool instead of exec_command."
|
||||
),
|
||||
turn.as_ref(),
|
||||
)
|
||||
.await;
|
||||
match apply_patch::apply_patch(turn, changes).await {
|
||||
match apply_patch::apply_patch(turn.as_ref(), changes).await {
|
||||
InternalApplyPatchInvocation::Output(item) => {
|
||||
let content = item?;
|
||||
Ok(Some(ToolOutput::Function {
|
||||
@@ -219,8 +222,12 @@ pub(crate) async fn intercept_apply_patch(
|
||||
let changes = convert_apply_patch_to_protocol(&apply.action);
|
||||
let approval_keys = file_paths_for_action(&apply.action);
|
||||
let emitter = ToolEmitter::apply_patch(changes.clone(), apply.auto_approved);
|
||||
let event_ctx =
|
||||
ToolEventCtx::new(session, turn, call_id, tracker.as_ref().copied());
|
||||
let event_ctx = ToolEventCtx::new(
|
||||
session.as_ref(),
|
||||
turn.as_ref(),
|
||||
call_id,
|
||||
tracker.as_ref().copied(),
|
||||
);
|
||||
emitter.begin(event_ctx).await;
|
||||
|
||||
let req = ApplyPatchRequest {
|
||||
@@ -235,8 +242,8 @@ pub(crate) async fn intercept_apply_patch(
|
||||
let mut orchestrator = ToolOrchestrator::new();
|
||||
let mut runtime = ApplyPatchRuntime::new();
|
||||
let tool_ctx = ToolCtx {
|
||||
session,
|
||||
turn,
|
||||
session: session.clone(),
|
||||
turn: turn.clone(),
|
||||
call_id: call_id.to_string(),
|
||||
tool_name: tool_name.to_string(),
|
||||
};
|
||||
@@ -245,13 +252,17 @@ pub(crate) async fn intercept_apply_patch(
|
||||
&mut runtime,
|
||||
&req,
|
||||
&tool_ctx,
|
||||
turn,
|
||||
turn.as_ref(),
|
||||
turn.approval_policy.value(),
|
||||
)
|
||||
.await
|
||||
.map(|result| result.output);
|
||||
let event_ctx =
|
||||
ToolEventCtx::new(session, turn, call_id, tracker.as_ref().copied());
|
||||
let event_ctx = ToolEventCtx::new(
|
||||
session.as_ref(),
|
||||
turn.as_ref(),
|
||||
call_id,
|
||||
tracker.as_ref().copied(),
|
||||
);
|
||||
let content = emitter.finish(event_ctx, out).await?;
|
||||
Ok(Some(ToolOutput::Function {
|
||||
body: FunctionCallOutputBody::Text(content),
|
||||
|
||||
@@ -93,6 +93,7 @@ impl ToolHandler for MultiAgentHandler {
|
||||
|
||||
mod spawn {
|
||||
use super::*;
|
||||
use crate::agent::role::DEFAULT_ROLE_NAME;
|
||||
use crate::agent::role::apply_role_to_config;
|
||||
|
||||
use crate::agent::exceeds_thread_spawn_depth_limit;
|
||||
@@ -109,6 +110,7 @@ mod spawn {
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SpawnAgentResult {
|
||||
agent_id: String,
|
||||
nickname: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn handle(
|
||||
@@ -183,6 +185,7 @@ mod spawn {
|
||||
.unwrap_or((None, None)),
|
||||
None => (None, None),
|
||||
};
|
||||
let nickname = new_agent_nickname.clone();
|
||||
session
|
||||
.send_event(
|
||||
&turn,
|
||||
@@ -199,9 +202,13 @@ mod spawn {
|
||||
)
|
||||
.await;
|
||||
let new_thread_id = result?;
|
||||
let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME);
|
||||
turn.otel_manager
|
||||
.counter("codex.multi_agent.spawn", 1, &[("role", role_tag)]);
|
||||
|
||||
let content = serde_json::to_string(&SpawnAgentResult {
|
||||
agent_id: new_thread_id.to_string(),
|
||||
nickname,
|
||||
})
|
||||
.map_err(|err| {
|
||||
FunctionCallError::Fatal(format!("failed to serialize spawn_agent result: {err}"))
|
||||
@@ -406,6 +413,8 @@ mod resume_agent {
|
||||
if let Some(err) = error {
|
||||
return Err(err);
|
||||
}
|
||||
turn.otel_manager
|
||||
.counter("codex.multi_agent.resume", 1, &[]);
|
||||
|
||||
let content = serde_json::to_string(&ResumeAgentResult { status }).map_err(|err| {
|
||||
FunctionCallError::Fatal(format!("failed to serialize resume_agent result: {err}"))
|
||||
@@ -1085,6 +1094,7 @@ mod tests {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SpawnAgentResult {
|
||||
agent_id: String,
|
||||
nickname: Option<String>,
|
||||
}
|
||||
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
@@ -1121,6 +1131,12 @@ mod tests {
|
||||
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");
|
||||
assert!(
|
||||
result
|
||||
.nickname
|
||||
.as_deref()
|
||||
.is_some_and(|nickname| !nickname.is_empty())
|
||||
);
|
||||
let snapshot = manager
|
||||
.get_thread(agent_id)
|
||||
.await
|
||||
@@ -1184,6 +1200,7 @@ mod tests {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SpawnAgentResult {
|
||||
agent_id: String,
|
||||
nickname: Option<String>,
|
||||
}
|
||||
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
@@ -1221,6 +1238,12 @@ mod tests {
|
||||
let result: SpawnAgentResult =
|
||||
serde_json::from_str(&content).expect("spawn_agent result should be json");
|
||||
assert!(!result.agent_id.is_empty());
|
||||
assert!(
|
||||
result
|
||||
.nickname
|
||||
.as_deref()
|
||||
.is_some_and(|nickname| !nickname.is_empty())
|
||||
);
|
||||
assert_eq!(success, Some(true));
|
||||
}
|
||||
|
||||
|
||||
@@ -296,8 +296,8 @@ impl ShellHandler {
|
||||
&exec_params.command,
|
||||
&exec_params.cwd,
|
||||
exec_params.expiration.timeout_ms(),
|
||||
session.as_ref(),
|
||||
turn.as_ref(),
|
||||
session.clone(),
|
||||
turn.clone(),
|
||||
Some(&tracker),
|
||||
&call_id,
|
||||
tool_name.as_str(),
|
||||
@@ -343,8 +343,8 @@ impl ShellHandler {
|
||||
let mut orchestrator = ToolOrchestrator::new();
|
||||
let mut runtime = ShellRuntime::new();
|
||||
let tool_ctx = ToolCtx {
|
||||
session: session.as_ref(),
|
||||
turn: turn.as_ref(),
|
||||
session: session.clone(),
|
||||
turn: turn.clone(),
|
||||
call_id: call_id.clone(),
|
||||
tool_name,
|
||||
};
|
||||
|
||||
@@ -172,8 +172,8 @@ impl ToolHandler for UnifiedExecHandler {
|
||||
&command,
|
||||
&cwd,
|
||||
Some(yield_time_ms),
|
||||
context.session.as_ref(),
|
||||
context.turn.as_ref(),
|
||||
context.session.clone(),
|
||||
context.turn.clone(),
|
||||
Some(&tracker),
|
||||
&context.call_id,
|
||||
tool_name.as_str(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use async_trait::async_trait;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
use codex_protocol::openai_models::InputModality;
|
||||
use serde::Deserialize;
|
||||
use tokio::fs;
|
||||
@@ -14,7 +15,6 @@ use crate::tools::handlers::parse_arguments;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::local_image_content_items_with_label_number;
|
||||
|
||||
pub struct ViewImageHandler;
|
||||
@@ -81,21 +81,20 @@ impl ToolHandler for ViewImageHandler {
|
||||
}
|
||||
let event_path = abs_path.clone();
|
||||
|
||||
let content: Vec<ContentItem> =
|
||||
local_image_content_items_with_label_number(&abs_path, None);
|
||||
let input = ResponseInputItem::Message {
|
||||
role: "user".to_string(),
|
||||
content,
|
||||
};
|
||||
|
||||
session
|
||||
.inject_response_items(vec![input])
|
||||
.await
|
||||
.map_err(|_| {
|
||||
FunctionCallError::RespondToModel(
|
||||
"unable to attach image (no active task)".to_string(),
|
||||
)
|
||||
})?;
|
||||
let content = local_image_content_items_with_label_number(&abs_path, None)
|
||||
.into_iter()
|
||||
.map(|item| match item {
|
||||
ContentItem::InputText { text } => {
|
||||
FunctionCallOutputContentItem::InputText { text }
|
||||
}
|
||||
ContentItem::InputImage { image_url } => {
|
||||
FunctionCallOutputContentItem::InputImage { image_url }
|
||||
}
|
||||
ContentItem::OutputText { text } => {
|
||||
FunctionCallOutputContentItem::InputText { text }
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
session
|
||||
.send_event(
|
||||
@@ -108,7 +107,7 @@ impl ToolHandler for ViewImageHandler {
|
||||
.await;
|
||||
|
||||
Ok(ToolOutput::Function {
|
||||
body: FunctionCallOutputBody::Text("attached local image path".to_string()),
|
||||
body: FunctionCallOutputBody::ContentItems(content),
|
||||
success: Some(true),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ impl ToolOrchestrator {
|
||||
async fn run_attempt<Rq, Out, T>(
|
||||
tool: &mut T,
|
||||
req: &Rq,
|
||||
tool_ctx: &ToolCtx<'_>,
|
||||
tool_ctx: &ToolCtx,
|
||||
attempt: &SandboxAttempt<'_>,
|
||||
has_managed_network_requirements: bool,
|
||||
) -> (Result<Out, ToolError>, Option<DeferredNetworkApproval>)
|
||||
@@ -56,7 +56,7 @@ impl ToolOrchestrator {
|
||||
T: ToolRuntime<Rq, Out>,
|
||||
{
|
||||
let network_approval = begin_network_approval(
|
||||
tool_ctx.session,
|
||||
&tool_ctx.session,
|
||||
&tool_ctx.turn.sub_id,
|
||||
&tool_ctx.call_id,
|
||||
has_managed_network_requirements,
|
||||
@@ -65,8 +65,8 @@ impl ToolOrchestrator {
|
||||
.await;
|
||||
|
||||
let attempt_tool_ctx = ToolCtx {
|
||||
session: tool_ctx.session,
|
||||
turn: tool_ctx.turn,
|
||||
session: tool_ctx.session.clone(),
|
||||
turn: tool_ctx.turn.clone(),
|
||||
call_id: tool_ctx.call_id.clone(),
|
||||
tool_name: tool_ctx.tool_name.clone(),
|
||||
};
|
||||
@@ -79,7 +79,7 @@ impl ToolOrchestrator {
|
||||
match network_approval.mode() {
|
||||
NetworkApprovalMode::Immediate => {
|
||||
let finalize_result =
|
||||
finish_immediate_network_approval(tool_ctx.session, network_approval).await;
|
||||
finish_immediate_network_approval(&tool_ctx.session, network_approval).await;
|
||||
if let Err(err) = finalize_result {
|
||||
return (Err(err), None);
|
||||
}
|
||||
@@ -88,7 +88,7 @@ impl ToolOrchestrator {
|
||||
NetworkApprovalMode::Deferred => {
|
||||
let deferred = network_approval.into_deferred();
|
||||
if run_result.is_err() {
|
||||
finish_deferred_network_approval(tool_ctx.session, deferred).await;
|
||||
finish_deferred_network_approval(&tool_ctx.session, deferred).await;
|
||||
return (run_result, None);
|
||||
}
|
||||
(run_result, deferred)
|
||||
@@ -100,7 +100,7 @@ impl ToolOrchestrator {
|
||||
&mut self,
|
||||
tool: &mut T,
|
||||
req: &Rq,
|
||||
tool_ctx: &ToolCtx<'_>,
|
||||
tool_ctx: &ToolCtx,
|
||||
turn_ctx: &crate::codex::TurnContext,
|
||||
approval_policy: AskForApproval,
|
||||
) -> Result<OrchestratorRunResult<Out>, ToolError>
|
||||
@@ -128,7 +128,7 @@ impl ToolOrchestrator {
|
||||
}
|
||||
ExecApprovalRequirement::NeedsApproval { reason, .. } => {
|
||||
let approval_ctx = ApprovalCtx {
|
||||
session: tool_ctx.session,
|
||||
session: &tool_ctx.session,
|
||||
turn: turn_ctx,
|
||||
call_id: &tool_ctx.call_id,
|
||||
retry_reason: reason,
|
||||
@@ -256,7 +256,7 @@ impl ToolOrchestrator {
|
||||
&& network_approval_context.is_none();
|
||||
if !bypass_retry_approval {
|
||||
let approval_ctx = ApprovalCtx {
|
||||
session: tool_ctx.session,
|
||||
session: &tool_ctx.session,
|
||||
turn: turn_ctx,
|
||||
call_id: &tool_ctx.call_id,
|
||||
retry_reason: Some(retry_reason),
|
||||
|
||||
@@ -70,7 +70,7 @@ impl ApplyPatchRuntime {
|
||||
})
|
||||
}
|
||||
|
||||
fn stdout_stream(ctx: &ToolCtx<'_>) -> Option<crate::exec::StdoutStream> {
|
||||
fn stdout_stream(ctx: &ToolCtx) -> Option<crate::exec::StdoutStream> {
|
||||
Some(crate::exec::StdoutStream {
|
||||
sub_id: ctx.turn.sub_id.clone(),
|
||||
call_id: ctx.call_id.clone(),
|
||||
@@ -156,7 +156,7 @@ impl ToolRuntime<ApplyPatchRequest, ExecToolCallOutput> for ApplyPatchRuntime {
|
||||
&mut self,
|
||||
req: &ApplyPatchRequest,
|
||||
attempt: &SandboxAttempt<'_>,
|
||||
ctx: &ToolCtx<'_>,
|
||||
ctx: &ToolCtx,
|
||||
) -> Result<ExecToolCallOutput, ToolError> {
|
||||
let spec = Self::build_command_spec(req)?;
|
||||
let env = attempt
|
||||
|
||||
@@ -60,7 +60,7 @@ impl ShellRuntime {
|
||||
Self
|
||||
}
|
||||
|
||||
fn stdout_stream(ctx: &ToolCtx<'_>) -> Option<crate::exec::StdoutStream> {
|
||||
fn stdout_stream(ctx: &ToolCtx) -> Option<crate::exec::StdoutStream> {
|
||||
Some(crate::exec::StdoutStream {
|
||||
sub_id: ctx.turn.sub_id.clone(),
|
||||
call_id: ctx.call_id.clone(),
|
||||
@@ -150,7 +150,7 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
|
||||
fn network_approval_spec(
|
||||
&self,
|
||||
req: &ShellRequest,
|
||||
_ctx: &ToolCtx<'_>,
|
||||
_ctx: &ToolCtx,
|
||||
) -> Option<NetworkApprovalSpec> {
|
||||
req.network.as_ref()?;
|
||||
Some(NetworkApprovalSpec {
|
||||
@@ -163,7 +163,7 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
|
||||
&mut self,
|
||||
req: &ShellRequest,
|
||||
attempt: &SandboxAttempt<'_>,
|
||||
ctx: &ToolCtx<'_>,
|
||||
ctx: &ToolCtx,
|
||||
) -> Result<ExecToolCallOutput, ToolError> {
|
||||
let base_command = &req.command;
|
||||
let session_shell = ctx.session.user_shell();
|
||||
@@ -207,7 +207,7 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
|
||||
.session
|
||||
.services
|
||||
.zsh_exec_bridge
|
||||
.execute_shell_request(&env, ctx.session, ctx.turn, &ctx.call_id)
|
||||
.execute_shell_request(&env, &ctx.session, &ctx.turn, &ctx.call_id)
|
||||
.await;
|
||||
}
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
|
||||
fn network_approval_spec(
|
||||
&self,
|
||||
req: &UnifiedExecRequest,
|
||||
_ctx: &ToolCtx<'_>,
|
||||
_ctx: &ToolCtx,
|
||||
) -> Option<NetworkApprovalSpec> {
|
||||
req.network.as_ref()?;
|
||||
Some(NetworkApprovalSpec {
|
||||
@@ -166,7 +166,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
|
||||
&mut self,
|
||||
req: &UnifiedExecRequest,
|
||||
attempt: &SandboxAttempt<'_>,
|
||||
ctx: &ToolCtx<'_>,
|
||||
ctx: &ToolCtx,
|
||||
) -> Result<UnifiedExecProcess, ToolError> {
|
||||
let base_command = &req.command;
|
||||
let session_shell = ctx.session.user_shell();
|
||||
|
||||
@@ -18,14 +18,14 @@ use codex_protocol::approvals::ExecPolicyAmendment;
|
||||
use codex_protocol::approvals::NetworkApprovalContext;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use futures::Future;
|
||||
use futures::future::BoxFuture;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Debug;
|
||||
use std::hash::Hash;
|
||||
use std::path::Path;
|
||||
|
||||
use futures::Future;
|
||||
use futures::future::BoxFuture;
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone, Default, Debug)]
|
||||
pub(crate) struct ApprovalStore {
|
||||
@@ -267,9 +267,9 @@ pub(crate) trait Sandboxable {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ToolCtx<'a> {
|
||||
pub session: &'a Session,
|
||||
pub turn: &'a TurnContext,
|
||||
pub(crate) struct ToolCtx {
|
||||
pub session: Arc<Session>,
|
||||
pub turn: Arc<TurnContext>,
|
||||
pub call_id: String,
|
||||
pub tool_name: String,
|
||||
}
|
||||
@@ -281,7 +281,7 @@ pub(crate) enum ToolError {
|
||||
}
|
||||
|
||||
pub(crate) trait ToolRuntime<Req, Out>: Approvable<Req> + Sandboxable {
|
||||
fn network_approval_spec(&self, _req: &Req, _ctx: &ToolCtx<'_>) -> Option<NetworkApprovalSpec> {
|
||||
fn network_approval_spec(&self, _req: &Req, _ctx: &ToolCtx) -> Option<NetworkApprovalSpec> {
|
||||
None
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,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_collaboration_modes_tools = features.enabled(Feature::CollaborationModes);
|
||||
let include_collaboration_modes_tools = true;
|
||||
let include_search_tool = features.enabled(Feature::Apps);
|
||||
|
||||
let shell_type = if !features.enabled(Feature::ShellTool) {
|
||||
@@ -557,7 +557,7 @@ fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec {
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "spawn_agent".to_string(),
|
||||
description:
|
||||
"Spawn a sub-agent for a well-scoped task. Returns the agent id to use to communicate with this agent."
|
||||
"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."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
@@ -1887,34 +1887,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_user_input_requires_collaboration_modes_feature() {
|
||||
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.disable(Feature::CollaborationModes);
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
features: &features,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
});
|
||||
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
||||
assert!(
|
||||
!tools.iter().any(|t| t.spec.name() == "request_user_input"),
|
||||
"request_user_input should be disabled when collaboration_modes feature is off"
|
||||
);
|
||||
|
||||
features.enable(Feature::CollaborationModes);
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
features: &features,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
});
|
||||
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
||||
assert_contains_tool_names(&tools, &["request_user_input"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn js_repl_requires_feature_flag() {
|
||||
let config = test_config();
|
||||
|
||||
@@ -594,8 +594,8 @@ impl UnifiedExecProcessManager {
|
||||
exec_approval_requirement,
|
||||
};
|
||||
let tool_ctx = ToolCtx {
|
||||
session: context.session.as_ref(),
|
||||
turn: context.turn.as_ref(),
|
||||
session: context.session.clone(),
|
||||
turn: context.turn.clone(),
|
||||
call_id: context.call_id.clone(),
|
||||
tool_name: "exec_command".to_string(),
|
||||
};
|
||||
@@ -604,7 +604,7 @@ impl UnifiedExecProcessManager {
|
||||
&mut runtime,
|
||||
&req,
|
||||
&tool_ctx,
|
||||
context.turn.as_ref(),
|
||||
&context.turn,
|
||||
context.turn.approval_policy.value(),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -166,6 +166,10 @@ impl ZshExecBridge {
|
||||
})?;
|
||||
|
||||
let mut cmd = tokio::process::Command::new(&command[0]);
|
||||
#[cfg(unix)]
|
||||
if let Some(arg0) = &req.arg0 {
|
||||
cmd.arg0(arg0);
|
||||
}
|
||||
if command.len() > 1 {
|
||||
cmd.args(&command[1..]);
|
||||
}
|
||||
@@ -459,7 +463,6 @@ fn run_exec_wrapper_mode() -> anyhow::Result<()> {
|
||||
argv: argv.clone(),
|
||||
cwd,
|
||||
};
|
||||
|
||||
let mut stream = StdUnixStream::connect(&socket_path)
|
||||
.with_context(|| format!("connect to wrapper socket at {socket_path}"))?;
|
||||
let encoded = serde_json::to_string(&request).context("serialize wrapper request")?;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#![expect(clippy::expect_used)]
|
||||
|
||||
use anyhow::Context as _;
|
||||
use anyhow::ensure;
|
||||
use codex_utils_cargo_bin::CargoBinError;
|
||||
use ctor::ctor;
|
||||
use tempfile::TempDir;
|
||||
@@ -99,6 +101,42 @@ pub fn test_tmp_path_buf() -> PathBuf {
|
||||
test_tmp_path().into_path_buf()
|
||||
}
|
||||
|
||||
/// Fetch a DotSlash resource and return the resolved executable/file path.
|
||||
pub fn fetch_dotslash_file(
|
||||
dotslash_file: &std::path::Path,
|
||||
dotslash_cache: Option<&std::path::Path>,
|
||||
) -> anyhow::Result<PathBuf> {
|
||||
let mut command = std::process::Command::new("dotslash");
|
||||
command.arg("--").arg("fetch").arg(dotslash_file);
|
||||
if let Some(dotslash_cache) = dotslash_cache {
|
||||
command.env("DOTSLASH_CACHE", dotslash_cache);
|
||||
}
|
||||
let output = command.output().with_context(|| {
|
||||
format!(
|
||||
"failed to run dotslash to fetch resource {}",
|
||||
dotslash_file.display()
|
||||
)
|
||||
})?;
|
||||
ensure!(
|
||||
output.status.success(),
|
||||
"dotslash fetch failed for {}: {}",
|
||||
dotslash_file.display(),
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
let fetched_path = String::from_utf8(output.stdout)
|
||||
.context("dotslash fetch output was not utf8")?
|
||||
.trim()
|
||||
.to_string();
|
||||
ensure!(!fetched_path.is_empty(), "dotslash fetch output was empty");
|
||||
let fetched_path = PathBuf::from(fetched_path);
|
||||
ensure!(
|
||||
fetched_path.is_file(),
|
||||
"dotslash returned non-file path: {}",
|
||||
fetched_path.display()
|
||||
);
|
||||
Ok(fetched_path)
|
||||
}
|
||||
|
||||
/// Returns a default `Config` whose on-disk state is confined to the provided
|
||||
/// temporary directory. Using a per-test directory keeps tests hermetic and
|
||||
/// avoids clobbering a developer’s real `~/.codex`.
|
||||
|
||||
@@ -566,7 +566,7 @@ fn message_input_texts(body: &Value, role: &str) -> Vec<String> {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn inbound_realtime_text_starts_turn_and_ignores_role() -> Result<()> {
|
||||
async fn inbound_realtime_text_starts_turn_for_assistant_role() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let api_server = start_mock_server().await;
|
||||
@@ -589,7 +589,7 @@ async fn inbound_realtime_text_starts_turn_and_ignores_role() -> Result<()> {
|
||||
"type": "conversation.item.added",
|
||||
"item": {
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "text from realtime"}]
|
||||
}
|
||||
}),
|
||||
@@ -633,6 +633,321 @@ async fn inbound_realtime_text_starts_turn_and_ignores_role() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn inbound_realtime_text_ignores_user_role_and_still_forwards_audio() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let api_server = start_mock_server().await;
|
||||
|
||||
let realtime_server = start_websocket_server(vec![vec![vec![
|
||||
json!({
|
||||
"type": "session.created",
|
||||
"session": { "id": "sess_ignore_user_role" }
|
||||
}),
|
||||
json!({
|
||||
"type": "conversation.item.added",
|
||||
"item": {
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "echoed local text"}]
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"type": "response.output_audio.delta",
|
||||
"delta": "AQID",
|
||||
"sample_rate": 24000,
|
||||
"num_channels": 1
|
||||
}),
|
||||
]]])
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex().with_config({
|
||||
let realtime_base_url = realtime_server.uri().to_string();
|
||||
move |config| {
|
||||
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
|
||||
}
|
||||
});
|
||||
let test = builder.build(&api_server).await?;
|
||||
|
||||
test.codex
|
||||
.submit(Op::RealtimeConversationStart(ConversationStartParams {
|
||||
prompt: "backend prompt".to_string(),
|
||||
session_id: None,
|
||||
}))
|
||||
.await?;
|
||||
|
||||
let _ = wait_for_event_match(&test.codex, |msg| match msg {
|
||||
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
|
||||
payload: RealtimeEvent::SessionCreated { session_id },
|
||||
}) if session_id == "sess_ignore_user_role" => Some(()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
let audio_out = tokio::time::timeout(
|
||||
Duration::from_millis(500),
|
||||
wait_for_event_match(&test.codex, |msg| match msg {
|
||||
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
|
||||
payload: RealtimeEvent::AudioOut(frame),
|
||||
}) => Some(frame.clone()),
|
||||
_ => None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("timed out waiting for realtime audio after user-role conversation item");
|
||||
assert_eq!(audio_out.data, "AQID");
|
||||
|
||||
let unexpected_turn_started = tokio::time::timeout(
|
||||
Duration::from_millis(200),
|
||||
wait_for_event_match(&test.codex, |msg| match msg {
|
||||
EventMsg::TurnStarted(_) => Some(()),
|
||||
_ => None,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert!(unexpected_turn_started.is_err());
|
||||
|
||||
realtime_server.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn delegated_turn_user_role_echo_does_not_redelegate_and_still_forwards_audio() -> Result<()>
|
||||
{
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let (gate_completed_tx, gate_completed_rx) = oneshot::channel();
|
||||
let first_chunks = vec![
|
||||
StreamingSseChunk {
|
||||
gate: None,
|
||||
body: sse_event(responses::ev_response_created("resp-1")),
|
||||
},
|
||||
StreamingSseChunk {
|
||||
gate: None,
|
||||
body: sse_event(responses::ev_assistant_message(
|
||||
"msg-1",
|
||||
"assistant says hi",
|
||||
)),
|
||||
},
|
||||
StreamingSseChunk {
|
||||
gate: Some(gate_completed_rx),
|
||||
body: sse_event(responses::ev_completed("resp-1")),
|
||||
},
|
||||
];
|
||||
let (api_server, completions) = start_streaming_sse_server(vec![first_chunks]).await;
|
||||
|
||||
let realtime_server = start_websocket_server(vec![vec![
|
||||
vec![
|
||||
json!({
|
||||
"type": "session.created",
|
||||
"session": { "id": "sess_echo_guard" }
|
||||
}),
|
||||
json!({
|
||||
"type": "conversation.item.added",
|
||||
"item": {
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "delegate now"}]
|
||||
}
|
||||
}),
|
||||
],
|
||||
vec![
|
||||
json!({
|
||||
"type": "conversation.item.added",
|
||||
"item": {
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "assistant says hi"}]
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"type": "response.output_audio.delta",
|
||||
"delta": "AQID",
|
||||
"sample_rate": 24000,
|
||||
"num_channels": 1
|
||||
}),
|
||||
],
|
||||
]])
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex().with_model("gpt-5.1").with_config({
|
||||
let realtime_base_url = realtime_server.uri().to_string();
|
||||
move |config| {
|
||||
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
|
||||
}
|
||||
});
|
||||
let test = builder.build_with_streaming_server(&api_server).await?;
|
||||
|
||||
test.codex
|
||||
.submit(Op::RealtimeConversationStart(ConversationStartParams {
|
||||
prompt: "backend prompt".to_string(),
|
||||
session_id: None,
|
||||
}))
|
||||
.await?;
|
||||
|
||||
let _ = wait_for_event_match(&test.codex, |msg| match msg {
|
||||
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
|
||||
payload: RealtimeEvent::SessionCreated { session_id },
|
||||
}) if session_id == "sess_echo_guard" => Some(()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
let _ = wait_for_event_match(&test.codex, |msg| match msg {
|
||||
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
|
||||
payload: RealtimeEvent::ConversationItemAdded(item),
|
||||
}) => item
|
||||
.get("content")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|content| content.get("text").and_then(Value::as_str) == Some("delegate now"))
|
||||
.then_some(()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
let audio_out = tokio::time::timeout(
|
||||
Duration::from_millis(500),
|
||||
wait_for_event_match(&test.codex, |msg| match msg {
|
||||
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
|
||||
payload: RealtimeEvent::AudioOut(frame),
|
||||
}) => Some(frame.clone()),
|
||||
_ => None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("timed out waiting for realtime audio after echoed user-role message");
|
||||
assert_eq!(audio_out.data, "AQID");
|
||||
|
||||
let completion = completions
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("missing delegated turn completion");
|
||||
let _ = gate_completed_tx.send(());
|
||||
completion
|
||||
.await
|
||||
.expect("delegated turn request did not complete");
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
|
||||
let requests = api_server.requests().await;
|
||||
assert_eq!(requests.len(), 1);
|
||||
|
||||
realtime_server.shutdown().await;
|
||||
api_server.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn inbound_realtime_text_does_not_block_realtime_event_forwarding() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let (gate_completed_tx, gate_completed_rx) = oneshot::channel();
|
||||
let first_chunks = vec![
|
||||
StreamingSseChunk {
|
||||
gate: None,
|
||||
body: sse_event(responses::ev_response_created("resp-1")),
|
||||
},
|
||||
StreamingSseChunk {
|
||||
gate: Some(gate_completed_rx),
|
||||
body: sse_event(responses::ev_completed("resp-1")),
|
||||
},
|
||||
];
|
||||
let (api_server, completions) = start_streaming_sse_server(vec![first_chunks]).await;
|
||||
|
||||
let realtime_server = start_websocket_server(vec![vec![vec![
|
||||
json!({
|
||||
"type": "session.created",
|
||||
"session": { "id": "sess_non_blocking" }
|
||||
}),
|
||||
json!({
|
||||
"type": "conversation.item.added",
|
||||
"item": {
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "delegate now"}]
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"type": "response.output_audio.delta",
|
||||
"delta": "AQID",
|
||||
"sample_rate": 24000,
|
||||
"num_channels": 1
|
||||
}),
|
||||
]]])
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex().with_model("gpt-5.1").with_config({
|
||||
let realtime_base_url = realtime_server.uri().to_string();
|
||||
move |config| {
|
||||
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
|
||||
}
|
||||
});
|
||||
let test = builder.build_with_streaming_server(&api_server).await?;
|
||||
|
||||
test.codex
|
||||
.submit(Op::RealtimeConversationStart(ConversationStartParams {
|
||||
prompt: "backend prompt".to_string(),
|
||||
session_id: None,
|
||||
}))
|
||||
.await?;
|
||||
|
||||
let _ = wait_for_event_match(&test.codex, |msg| match msg {
|
||||
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
|
||||
payload: RealtimeEvent::SessionCreated { session_id },
|
||||
}) if session_id == "sess_non_blocking" => Some(()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
let _ = wait_for_event_match(&test.codex, |msg| match msg {
|
||||
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
|
||||
payload: RealtimeEvent::ConversationItemAdded(item),
|
||||
}) => item
|
||||
.get("content")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|content| content.get("text").and_then(Value::as_str) == Some("delegate now"))
|
||||
.then_some(()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
let audio_out = tokio::time::timeout(
|
||||
Duration::from_millis(500),
|
||||
wait_for_event_match(&test.codex, |msg| match msg {
|
||||
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
|
||||
payload: RealtimeEvent::AudioOut(frame),
|
||||
}) => Some(frame.clone()),
|
||||
_ => None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("timed out waiting for realtime audio while delegated turn was still pending");
|
||||
assert_eq!(audio_out.data, "AQID");
|
||||
|
||||
let completion = completions
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("missing delegated turn completion");
|
||||
let _ = gate_completed_tx.send(());
|
||||
completion
|
||||
.await
|
||||
.expect("delegated turn request did not complete");
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
|
||||
realtime_server.shutdown().await;
|
||||
api_server.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn inbound_realtime_text_steers_active_turn() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
@@ -791,3 +1106,121 @@ async fn inbound_realtime_text_steers_active_turn() -> Result<()> {
|
||||
api_server.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn inbound_spawn_transcript_starts_turn_and_does_not_block_realtime_audio() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let (gate_completed_tx, gate_completed_rx) = oneshot::channel();
|
||||
let first_chunks = vec![
|
||||
StreamingSseChunk {
|
||||
gate: None,
|
||||
body: sse_event(responses::ev_response_created("resp-1")),
|
||||
},
|
||||
StreamingSseChunk {
|
||||
gate: Some(gate_completed_rx),
|
||||
body: sse_event(responses::ev_completed("resp-1")),
|
||||
},
|
||||
];
|
||||
let (api_server, completions) = start_streaming_sse_server(vec![first_chunks]).await;
|
||||
|
||||
let delegated_text = "delegate from spawn transcript";
|
||||
let realtime_server = start_websocket_server(vec![vec![vec![
|
||||
json!({
|
||||
"type": "session.created",
|
||||
"session": { "id": "sess_spawn_transcript" }
|
||||
}),
|
||||
json!({
|
||||
"type": "conversation.item.added",
|
||||
"item": {
|
||||
"type": "spawn_transcript",
|
||||
"seq": 1,
|
||||
"full_user_transcript": delegated_text,
|
||||
"delta_user_transcript": delegated_text,
|
||||
"backend_prompt_messages": [{
|
||||
"role": "user",
|
||||
"channel": null,
|
||||
"content": delegated_text,
|
||||
"content_type": "text"
|
||||
}],
|
||||
"transcript_source": "backend_prompt_messages"
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"type": "response.output_audio.delta",
|
||||
"delta": "AQID",
|
||||
"sample_rate": 24000,
|
||||
"num_channels": 1
|
||||
}),
|
||||
]]])
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex().with_model("gpt-5.1").with_config({
|
||||
let realtime_base_url = realtime_server.uri().to_string();
|
||||
move |config| {
|
||||
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
|
||||
}
|
||||
});
|
||||
let test = builder.build_with_streaming_server(&api_server).await?;
|
||||
|
||||
test.codex
|
||||
.submit(Op::RealtimeConversationStart(ConversationStartParams {
|
||||
prompt: "backend prompt".to_string(),
|
||||
session_id: None,
|
||||
}))
|
||||
.await?;
|
||||
|
||||
let _ = wait_for_event_match(&test.codex, |msg| match msg {
|
||||
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
|
||||
payload: RealtimeEvent::SessionCreated { session_id },
|
||||
}) if session_id == "sess_spawn_transcript" => Some(()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
let _ = wait_for_event_match(&test.codex, |msg| match msg {
|
||||
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
|
||||
payload: RealtimeEvent::ConversationItemAdded(item),
|
||||
}) => (item.get("type").and_then(Value::as_str) == Some("spawn_transcript")
|
||||
&& item.get("delta_user_transcript").and_then(Value::as_str) == Some(delegated_text))
|
||||
.then_some(()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
let audio_out = tokio::time::timeout(
|
||||
Duration::from_millis(500),
|
||||
wait_for_event_match(&test.codex, |msg| match msg {
|
||||
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
|
||||
payload: RealtimeEvent::AudioOut(frame),
|
||||
}) => Some(frame.clone()),
|
||||
_ => None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("timed out waiting for realtime audio after spawn_transcript");
|
||||
assert_eq!(audio_out.data, "AQID");
|
||||
|
||||
let completion = completions
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("missing delegated turn completion");
|
||||
let _ = gate_completed_tx.send(());
|
||||
completion
|
||||
.await
|
||||
.expect("delegated turn request did not complete");
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
|
||||
let requests = api_server.requests().await;
|
||||
assert_eq!(requests.len(), 1);
|
||||
let first_body: Value = serde_json::from_slice(&requests[0]).expect("parse first request");
|
||||
let first_texts = message_input_texts(&first_body, "user");
|
||||
assert!(first_texts.iter().any(|text| text == delegated_text));
|
||||
|
||||
realtime_server.shutdown().await;
|
||||
api_server.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -237,40 +237,29 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> {
|
||||
|
||||
let req = mock.single_request();
|
||||
let body = req.body_json();
|
||||
let output_text = req
|
||||
.function_call_output_content_and_success(call_id)
|
||||
.and_then(|(content, _)| content)
|
||||
.expect("output text present");
|
||||
assert_eq!(output_text, "attached local image path");
|
||||
assert!(
|
||||
find_image_message(&body).is_none(),
|
||||
"view_image tool should not inject a separate image message"
|
||||
);
|
||||
|
||||
let image_message =
|
||||
find_image_message(&body).expect("pending input image message not included in request");
|
||||
let content_items = image_message
|
||||
.get("content")
|
||||
let function_output = req.function_call_output(call_id);
|
||||
let output_items = function_output
|
||||
.get("output")
|
||||
.and_then(Value::as_array)
|
||||
.expect("image message has content array");
|
||||
.expect("function_call_output should be a content item array");
|
||||
assert_eq!(
|
||||
content_items.len(),
|
||||
output_items.len(),
|
||||
1,
|
||||
"view_image should inject only the image content item (no tag/label text)"
|
||||
"view_image should return only the image content item (no tag/label text)"
|
||||
);
|
||||
assert_eq!(
|
||||
content_items[0].get("type").and_then(Value::as_str),
|
||||
output_items[0].get("type").and_then(Value::as_str),
|
||||
Some("input_image"),
|
||||
"view_image should inject only an input_image content item"
|
||||
"view_image should return only an input_image content item"
|
||||
);
|
||||
let image_url = image_message
|
||||
.get("content")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|content| {
|
||||
content.iter().find_map(|span| {
|
||||
if span.get("type").and_then(Value::as_str) == Some("input_image") {
|
||||
span.get("image_url").and_then(Value::as_str)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
let image_url = output_items[0]
|
||||
.get("image_url")
|
||||
.and_then(Value::as_str)
|
||||
.expect("image_url present");
|
||||
|
||||
let (prefix, encoded) = image_url
|
||||
@@ -535,38 +524,36 @@ async fn view_image_tool_placeholder_for_non_image_files() -> anyhow::Result<()>
|
||||
request.inputs_of_type("input_image").is_empty(),
|
||||
"non-image file should not produce an input_image message"
|
||||
);
|
||||
let function_output = request.function_call_output(call_id);
|
||||
let output_items = function_output
|
||||
.get("output")
|
||||
.and_then(Value::as_array)
|
||||
.expect("function_call_output should be a content item array");
|
||||
assert_eq!(
|
||||
output_items.len(),
|
||||
1,
|
||||
"non-image placeholder should be returned as a single content item"
|
||||
);
|
||||
assert_eq!(
|
||||
output_items[0].get("type").and_then(Value::as_str),
|
||||
Some("input_text"),
|
||||
"non-image placeholder should be returned as input_text"
|
||||
);
|
||||
let placeholder = output_items[0]
|
||||
.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.expect("placeholder text present");
|
||||
|
||||
let placeholder = request
|
||||
.inputs_of_type("message")
|
||||
.iter()
|
||||
.find_map(|item| {
|
||||
let content = item.get("content").and_then(Value::as_array)?;
|
||||
content.iter().find_map(|span| {
|
||||
if span.get("type").and_then(Value::as_str) == Some("input_text") {
|
||||
let text = span.get("text").and_then(Value::as_str)?;
|
||||
if text.contains("Codex could not read the local image at")
|
||||
&& text.contains("unsupported MIME type `application/json`")
|
||||
{
|
||||
return Some(text.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
})
|
||||
.expect("placeholder text found");
|
||||
|
||||
assert!(
|
||||
placeholder.contains("Codex could not read the local image at")
|
||||
&& placeholder.contains("unsupported MIME type `application/json`"),
|
||||
"placeholder should describe the unsupported file type: {placeholder}"
|
||||
);
|
||||
assert!(
|
||||
placeholder.contains(&abs_path.display().to_string()),
|
||||
"placeholder should mention path: {placeholder}"
|
||||
);
|
||||
|
||||
let output_text = mock
|
||||
.single_request()
|
||||
.function_call_output_content_and_success(call_id)
|
||||
.and_then(|(content, _)| content)
|
||||
.expect("output text present");
|
||||
assert_eq!(output_text, "attached local image path");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[package]
|
||||
name = "codex-exec-server"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "codex-execve-wrapper"
|
||||
@@ -19,6 +19,11 @@ path = "src/lib.rs"
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[package.metadata.cargo-shear]
|
||||
# This appears to be due to #[derive(rmcp::schemars::JsonSchema)], which
|
||||
# requires use of schemars via a macro that shear cannot detect.
|
||||
ignored = ["schemars"]
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
@@ -27,8 +32,7 @@ codex-core = { workspace = true }
|
||||
codex-execpolicy = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-shell-command = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
path-absolutize = { workspace = true }
|
||||
codex-shell-escalation = { workspace = true }
|
||||
rmcp = { workspace = true, default-features = false, features = [
|
||||
"auth",
|
||||
"elicitation",
|
||||
@@ -42,24 +46,18 @@ rmcp = { workspace = true, default-features = false, features = [
|
||||
"transport-streamable-http-server",
|
||||
"transport-io",
|
||||
] }
|
||||
schemars = { version = "1.2.1" }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
shlex = { workspace = true }
|
||||
socket2 = { workspace = true }
|
||||
tokio = { workspace = true, features = [
|
||||
"io-std",
|
||||
"macros",
|
||||
"process",
|
||||
"rt-multi-thread",
|
||||
"signal",
|
||||
] }
|
||||
tokio-util = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal"] }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] }
|
||||
|
||||
[dev-dependencies]
|
||||
codex-utils-cargo-bin = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-utils-cargo-bin = { workspace = true }
|
||||
core_test_support = { workspace = true }
|
||||
exec_server_test_support = { workspace = true }
|
||||
maplit = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
#[cfg(unix)]
|
||||
mod posix;
|
||||
mod unix;
|
||||
|
||||
#[cfg(unix)]
|
||||
pub use posix::main_execve_wrapper;
|
||||
|
||||
#[cfg(unix)]
|
||||
pub use posix::main_mcp_server;
|
||||
|
||||
#[cfg(unix)]
|
||||
pub use posix::ExecResult;
|
||||
pub use unix::*;
|
||||
|
||||
@@ -67,21 +67,16 @@ use codex_execpolicy::Decision;
|
||||
use codex_execpolicy::Policy;
|
||||
use codex_execpolicy::RuleMatch;
|
||||
use codex_shell_command::is_dangerous_command::command_might_be_dangerous;
|
||||
use codex_shell_escalation as shell_escalation;
|
||||
use rmcp::ErrorData as McpError;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use tracing_subscriber::{self};
|
||||
|
||||
use crate::posix::mcp_escalation_policy::ExecPolicyOutcome;
|
||||
use crate::unix::mcp_escalation_policy::ExecPolicyOutcome;
|
||||
|
||||
mod escalate_client;
|
||||
mod escalate_protocol;
|
||||
mod escalate_server;
|
||||
mod escalation_policy;
|
||||
mod mcp;
|
||||
mod mcp_escalation_policy;
|
||||
mod socket;
|
||||
mod stopwatch;
|
||||
|
||||
pub use mcp::ExecResult;
|
||||
|
||||
@@ -165,7 +160,7 @@ pub async fn main_execve_wrapper() -> anyhow::Result<()> {
|
||||
.init();
|
||||
|
||||
let ExecveWrapperCli { file, argv } = ExecveWrapperCli::parse();
|
||||
let exit_code = escalate_client::run(file, argv).await?;
|
||||
let exit_code = shell_escalation::run(file, argv).await?;
|
||||
std::process::exit(exit_code);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ use codex_core::MCP_SANDBOX_STATE_METHOD;
|
||||
use codex_core::SandboxState;
|
||||
use codex_execpolicy::Policy;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_shell_escalation::EscalationPolicyFactory;
|
||||
use codex_shell_escalation::run_escalate_server;
|
||||
use rmcp::ErrorData as McpError;
|
||||
use rmcp::RoleServer;
|
||||
use rmcp::ServerHandler;
|
||||
@@ -18,7 +20,6 @@ use rmcp::handler::server::wrapper::Parameters;
|
||||
use rmcp::model::CustomRequest;
|
||||
use rmcp::model::CustomResult;
|
||||
use rmcp::model::*;
|
||||
use rmcp::schemars;
|
||||
use rmcp::service::RequestContext;
|
||||
use rmcp::service::RunningService;
|
||||
use rmcp::tool;
|
||||
@@ -28,10 +29,7 @@ use rmcp::transport::stdio;
|
||||
use serde_json::json;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::posix::escalate_server::EscalateServer;
|
||||
use crate::posix::escalate_server::{self};
|
||||
use crate::posix::mcp_escalation_policy::McpEscalationPolicy;
|
||||
use crate::posix::stopwatch::Stopwatch;
|
||||
use crate::unix::mcp_escalation_policy::McpEscalationPolicy;
|
||||
|
||||
/// Path to our patched bash.
|
||||
const CODEX_BASH_PATH_ENV_VAR: &str = "CODEX_BASH_PATH";
|
||||
@@ -44,19 +42,7 @@ pub(crate) fn get_bash_path() -> Result<PathBuf> {
|
||||
.context(format!("{CODEX_BASH_PATH_ENV_VAR} must be set"))
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct ExecParams {
|
||||
/// The bash string to execute.
|
||||
pub command: String,
|
||||
/// The working directory to execute the command in. Must be an absolute path.
|
||||
pub workdir: String,
|
||||
/// The timeout for the command in milliseconds.
|
||||
pub timeout_ms: Option<u64>,
|
||||
/// Launch Bash with -lc instead of -c: defaults to true.
|
||||
pub login: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ExecResult {
|
||||
pub exit_code: i32,
|
||||
pub output: String,
|
||||
@@ -64,8 +50,8 @@ pub struct ExecResult {
|
||||
pub timed_out: bool,
|
||||
}
|
||||
|
||||
impl From<escalate_server::ExecResult> for ExecResult {
|
||||
fn from(result: escalate_server::ExecResult) -> Self {
|
||||
impl From<codex_shell_escalation::ExecResult> for ExecResult {
|
||||
fn from(result: codex_shell_escalation::ExecResult) -> Self {
|
||||
Self {
|
||||
exit_code: result.exit_code,
|
||||
output: result.output,
|
||||
@@ -85,6 +71,51 @@ pub struct ExecTool {
|
||||
sandbox_state: Arc<RwLock<Option<SandboxState>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize, rmcp::schemars::JsonSchema)]
|
||||
pub struct ExecParams {
|
||||
/// The bash string to execute.
|
||||
pub command: String,
|
||||
/// The working directory to execute the command in. Must be an absolute path.
|
||||
pub workdir: String,
|
||||
/// The timeout for the command in milliseconds.
|
||||
pub timeout_ms: Option<u64>,
|
||||
/// Launch Bash with -lc instead of -c: defaults to true.
|
||||
pub login: Option<bool>,
|
||||
}
|
||||
|
||||
impl From<ExecParams> for codex_shell_escalation::ExecParams {
|
||||
fn from(inner: ExecParams) -> Self {
|
||||
Self {
|
||||
command: inner.command,
|
||||
workdir: inner.workdir,
|
||||
timeout_ms: inner.timeout_ms,
|
||||
login: inner.login,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct McpEscalationPolicyFactory {
|
||||
context: RequestContext<RoleServer>,
|
||||
preserve_program_paths: bool,
|
||||
}
|
||||
|
||||
impl EscalationPolicyFactory for McpEscalationPolicyFactory {
|
||||
type Policy = McpEscalationPolicy;
|
||||
|
||||
fn create_policy(
|
||||
&self,
|
||||
policy: Arc<RwLock<Policy>>,
|
||||
stopwatch: codex_shell_escalation::Stopwatch,
|
||||
) -> Self::Policy {
|
||||
McpEscalationPolicy::new(
|
||||
policy,
|
||||
self.context.clone(),
|
||||
stopwatch,
|
||||
self.preserve_program_paths,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_router]
|
||||
impl ExecTool {
|
||||
pub fn new(
|
||||
@@ -115,8 +146,6 @@ impl ExecTool {
|
||||
.timeout_ms
|
||||
.unwrap_or(codex_core::exec::DEFAULT_EXEC_COMMAND_TIMEOUT_MS),
|
||||
);
|
||||
let stopwatch = Stopwatch::new(effective_timeout);
|
||||
let cancel_token = stopwatch.cancellation_token();
|
||||
let sandbox_state =
|
||||
self.sandbox_state
|
||||
.read()
|
||||
@@ -128,21 +157,20 @@ impl ExecTool {
|
||||
sandbox_cwd: PathBuf::from(¶ms.workdir),
|
||||
use_linux_sandbox_bwrap: false,
|
||||
});
|
||||
let escalate_server = EscalateServer::new(
|
||||
self.bash_path.clone(),
|
||||
self.execve_wrapper.clone(),
|
||||
McpEscalationPolicy::new(
|
||||
self.policy.clone(),
|
||||
let result = run_escalate_server(
|
||||
params.into(),
|
||||
&sandbox_state,
|
||||
&self.bash_path,
|
||||
&self.execve_wrapper,
|
||||
self.policy.clone(),
|
||||
McpEscalationPolicyFactory {
|
||||
context,
|
||||
stopwatch.clone(),
|
||||
self.preserve_program_paths,
|
||||
),
|
||||
);
|
||||
|
||||
let result = escalate_server
|
||||
.exec(params, cancel_token, &sandbox_state)
|
||||
.await
|
||||
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
|
||||
preserve_program_paths: self.preserve_program_paths,
|
||||
},
|
||||
effective_timeout,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
|
||||
Ok(CallToolResult::success(vec![Content::json(
|
||||
ExecResult::from(result),
|
||||
)?]))
|
||||
@@ -242,7 +270,7 @@ mod tests {
|
||||
/// `timeout_ms` fields are optional.
|
||||
#[test]
|
||||
fn exec_params_json_schema_matches_expected() {
|
||||
let schema = schemars::schema_for!(ExecParams);
|
||||
let schema = rmcp::schemars::schema_for!(ExecParams);
|
||||
let actual = serde_json::to_value(schema).expect("schema should serialize");
|
||||
|
||||
assert_eq!(
|
||||
+7
-7
@@ -2,6 +2,9 @@ use std::path::Path;
|
||||
|
||||
use codex_core::sandboxing::SandboxPermissions;
|
||||
use codex_execpolicy::Policy;
|
||||
use codex_shell_escalation::EscalateAction;
|
||||
use codex_shell_escalation::EscalationPolicy;
|
||||
use codex_shell_escalation::Stopwatch;
|
||||
use rmcp::ErrorData as McpError;
|
||||
use rmcp::RoleServer;
|
||||
use rmcp::model::CreateElicitationRequestParams;
|
||||
@@ -9,10 +12,7 @@ use rmcp::model::CreateElicitationResult;
|
||||
use rmcp::model::ElicitationAction;
|
||||
use rmcp::model::ElicitationSchema;
|
||||
use rmcp::service::RequestContext;
|
||||
|
||||
use crate::posix::escalate_protocol::EscalateAction;
|
||||
use crate::posix::escalation_policy::EscalationPolicy;
|
||||
use crate::posix::stopwatch::Stopwatch;
|
||||
use shlex::try_join;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
@@ -59,7 +59,7 @@ impl McpEscalationPolicy {
|
||||
workdir: &Path,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> Result<CreateElicitationResult, McpError> {
|
||||
let args = shlex::try_join(argv.iter().skip(1).map(String::as_str)).unwrap_or_default();
|
||||
let args = try_join(argv.iter().skip(1).map(String::as_str)).unwrap_or_default();
|
||||
let command = if args.is_empty() {
|
||||
file.display().to_string()
|
||||
} else {
|
||||
@@ -104,10 +104,10 @@ impl EscalationPolicy for McpEscalationPolicy {
|
||||
file: &Path,
|
||||
argv: &[String],
|
||||
workdir: &Path,
|
||||
) -> Result<EscalateAction, rmcp::ErrorData> {
|
||||
) -> anyhow::Result<EscalateAction> {
|
||||
let policy = self.policy.read().await;
|
||||
let outcome =
|
||||
crate::posix::evaluate_exec_policy(&policy, file, argv, self.preserve_program_paths)?;
|
||||
crate::unix::evaluate_exec_policy(&policy, file, argv, self.preserve_program_paths)?;
|
||||
let action = match outcome {
|
||||
ExecPolicyOutcome::Allow {
|
||||
sandbox_permissions,
|
||||
@@ -61,15 +61,9 @@ prefix_rule(
|
||||
/// Verify the same prompt/escalation flow works when the server is launched
|
||||
/// with a patched zsh binary.
|
||||
///
|
||||
/// Set CODEX_TEST_ZSH_PATH to enable this test locally or in CI.
|
||||
/// The suite resolves `tests/suite/zsh` via DotSlash on first use.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn accept_elicitation_for_prompt_rule_with_zsh() -> Result<()> {
|
||||
let Some(zsh_path) = std::env::var_os("CODEX_TEST_ZSH_PATH") else {
|
||||
eprintln!("skipping zsh test: CODEX_TEST_ZSH_PATH is not set");
|
||||
return Ok(());
|
||||
};
|
||||
let zsh_path = PathBuf::from(zsh_path);
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
write_default_execpolicy(
|
||||
r#"
|
||||
@@ -87,6 +81,11 @@ prefix_rule(
|
||||
.await?;
|
||||
let dotslash_cache_temp_dir = TempDir::new()?;
|
||||
let dotslash_cache = dotslash_cache_temp_dir.path();
|
||||
let zsh_path = resolve_test_zsh_path(dotslash_cache).await?;
|
||||
eprintln!(
|
||||
"using zsh path for exec-server test: {}",
|
||||
zsh_path.display()
|
||||
);
|
||||
let transport =
|
||||
create_transport_with_shell_path(codex_home.as_ref(), dotslash_cache, &zsh_path).await?;
|
||||
run_accept_elicitation_for_prompt_rule_with_transport(transport).await
|
||||
@@ -95,13 +94,13 @@ prefix_rule(
|
||||
async fn run_accept_elicitation_for_prompt_rule_with_transport(
|
||||
transport: rmcp::transport::TokioChildProcess,
|
||||
) -> Result<()> {
|
||||
// Create an MCP client that approves expected elicitation messages.
|
||||
// Create an MCP client that approves the expected elicitation message.
|
||||
let project_root = TempDir::new()?;
|
||||
let project_root_path = project_root.path().canonicalize().unwrap();
|
||||
let git_path = resolve_git_path(USE_LOGIN_SHELL).await?;
|
||||
let git_init_command = format!("{git_path} init --quiet .");
|
||||
let expected_elicitation_message = format!(
|
||||
"Allow agent to run `{} init .` in `{}`?",
|
||||
git_path,
|
||||
"Allow agent to run `{git_path} init --quiet .` in `{}`?",
|
||||
project_root_path.display()
|
||||
);
|
||||
let elicitation_requests: Arc<Mutex<Vec<CreateElicitationRequestParams>>> = Default::default();
|
||||
@@ -142,7 +141,7 @@ async fn run_accept_elicitation_for_prompt_rule_with_transport(
|
||||
arguments: Some(object(json!(
|
||||
{
|
||||
"login": USE_LOGIN_SHELL,
|
||||
"command": "git init .",
|
||||
"command": git_init_command,
|
||||
"workdir": project_root_path.to_string_lossy(),
|
||||
}
|
||||
))),
|
||||
@@ -157,15 +156,11 @@ async fn run_accept_elicitation_for_prompt_rule_with_transport(
|
||||
let ExecResult {
|
||||
exit_code, output, ..
|
||||
} = serde_json::from_str::<ExecResult>(&tool_call_content.text)?;
|
||||
let git_init_succeeded = format!(
|
||||
"Initialized empty Git repository in {}/.git/\n",
|
||||
project_root_path.display()
|
||||
);
|
||||
// Normally, this would be an exact match, but it might include extra output
|
||||
// if `git config set advice.defaultBranchName false` has not been set.
|
||||
// `git init --quiet` is expected to suppress the usual initialization
|
||||
// banner, so assert on success and filesystem effects instead of output.
|
||||
assert!(
|
||||
output.contains(&git_init_succeeded),
|
||||
"expected output `{output}` to contain `{git_init_succeeded}`"
|
||||
output.is_empty(),
|
||||
"expected no output from `git init --quiet .`, got `{output}`"
|
||||
);
|
||||
assert_eq!(exit_code, 0, "command should succeed");
|
||||
assert_eq!(is_error, Some(false), "command should succeed");
|
||||
@@ -192,6 +187,12 @@ async fn run_accept_elicitation_for_prompt_rule_with_transport(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn resolve_test_zsh_path(dotslash_cache: &std::path::Path) -> Result<PathBuf> {
|
||||
let dotslash_zsh = codex_utils_cargo_bin::find_resource!("tests/suite/zsh")?;
|
||||
core_test_support::fetch_dotslash_file(&dotslash_zsh, Some(dotslash_cache))
|
||||
.with_context(|| format!("failed to fetch test zsh from {}", dotslash_zsh.display()))
|
||||
}
|
||||
|
||||
fn ensure_codex_cli() -> Result<PathBuf> {
|
||||
let codex_cli = codex_utils_cargo_bin::cargo_bin("codex")?;
|
||||
|
||||
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env dotslash
|
||||
|
||||
// This is the patched zsh fork built by
|
||||
// `.github/workflows/shell-tool-mcp.yml` for the shell-tool-mcp package.
|
||||
// Fetching the prebuilt version via DotSlash makes it easier to write
|
||||
// integration tests that exercise the zsh fork behavior in exec-server tests.
|
||||
//
|
||||
// TODO(mbolin): Currently, we use a .tgz artifact that includes binaries for
|
||||
// multiple platforms, but we could save a bit of space by making arch-specific
|
||||
// artifacts available in the GitHub releases and referencing those here.
|
||||
{
|
||||
"name": "codex-zsh",
|
||||
"platforms": {
|
||||
// macOS 13 builds (and therefore x86_64) were dropped in
|
||||
// https://github.com/openai/codex/pull/7295, so we only provide an
|
||||
// Apple Silicon build for now.
|
||||
"macos-aarch64": {
|
||||
"size": 53771483,
|
||||
"hash": "blake3",
|
||||
"digest": "ff664f63f5e1fa62762c9aff0aafa66cf196faf9b157f98ec98f59c152fc7bd3",
|
||||
"format": "tar.gz",
|
||||
"path": "package/vendor/aarch64-apple-darwin/zsh/macos-15/zsh",
|
||||
"providers": [
|
||||
{
|
||||
"url": "https://github.com/openai/codex/releases/download/rust-v0.104.0/codex-shell-tool-mcp-npm-0.104.0.tgz"
|
||||
},
|
||||
{
|
||||
"type": "github-release",
|
||||
"repo": "openai/codex",
|
||||
"tag": "rust-v0.104.0",
|
||||
"name": "codex-shell-tool-mcp-npm-0.104.0.tgz"
|
||||
}
|
||||
]
|
||||
},
|
||||
"linux-x86_64": {
|
||||
"size": 53771483,
|
||||
"hash": "blake3",
|
||||
"digest": "ff664f63f5e1fa62762c9aff0aafa66cf196faf9b157f98ec98f59c152fc7bd3",
|
||||
"format": "tar.gz",
|
||||
"path": "package/vendor/x86_64-unknown-linux-musl/zsh/ubuntu-24.04/zsh",
|
||||
"providers": [
|
||||
{
|
||||
"url": "https://github.com/openai/codex/releases/download/rust-v0.104.0/codex-shell-tool-mcp-npm-0.104.0.tgz"
|
||||
},
|
||||
{
|
||||
"type": "github-release",
|
||||
"repo": "openai/codex",
|
||||
"tag": "rust-v0.104.0",
|
||||
"name": "codex-shell-tool-mcp-npm-0.104.0.tgz"
|
||||
}
|
||||
]
|
||||
},
|
||||
"linux-aarch64": {
|
||||
"size": 53771483,
|
||||
"hash": "blake3",
|
||||
"digest": "ff664f63f5e1fa62762c9aff0aafa66cf196faf9b157f98ec98f59c152fc7bd3",
|
||||
"format": "tar.gz",
|
||||
"path": "package/vendor/aarch64-unknown-linux-musl/zsh/ubuntu-24.04/zsh",
|
||||
"providers": [
|
||||
{
|
||||
"url": "https://github.com/openai/codex/releases/download/rust-v0.104.0/codex-shell-tool-mcp-npm-0.104.0.tgz"
|
||||
},
|
||||
{
|
||||
"type": "github-release",
|
||||
"repo": "openai/codex",
|
||||
"tag": "rust-v0.104.0",
|
||||
"name": "codex-shell-tool-mcp-npm-0.104.0.tgz"
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,12 @@ pub struct Cli {
|
||||
pub json: bool,
|
||||
|
||||
/// Specifies file where the last message from the agent should be written.
|
||||
#[arg(long = "output-last-message", short = 'o', value_name = "FILE")]
|
||||
#[arg(
|
||||
long = "output-last-message",
|
||||
short = 'o',
|
||||
value_name = "FILE",
|
||||
global = true
|
||||
)]
|
||||
pub last_message_file: Option<PathBuf>,
|
||||
|
||||
/// Initial instructions for the agent. If not provided as an argument (or
|
||||
@@ -283,4 +288,27 @@ mod tests {
|
||||
});
|
||||
assert_eq!(effective_prompt.as_deref(), Some(PROMPT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_accepts_output_last_message_flag_after_subcommand() {
|
||||
const PROMPT: &str = "echo resume-with-output-file";
|
||||
let cli = Cli::parse_from([
|
||||
"codex-exec",
|
||||
"resume",
|
||||
"session-123",
|
||||
"-o",
|
||||
"/tmp/resume-output.md",
|
||||
PROMPT,
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
cli.last_message_file,
|
||||
Some(PathBuf::from("/tmp/resume-output.md"))
|
||||
);
|
||||
let Some(Command::Resume(args)) = cli.command else {
|
||||
panic!("expected resume command");
|
||||
};
|
||||
assert_eq!(args.session_id.as_deref(), Some("session-123"));
|
||||
assert_eq!(args.prompt.as_deref(), Some(PROMPT));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,7 @@ use codex_utils_cargo_bin::find_resource;
|
||||
use core_test_support::test_codex_exec::test_codex_exec;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use std::fs::FileTimes;
|
||||
use std::fs::OpenOptions;
|
||||
use std::string::ToString;
|
||||
use std::time::Duration;
|
||||
use std::time::SystemTime;
|
||||
use tempfile::TempDir;
|
||||
use uuid::Uuid;
|
||||
use walkdir::WalkDir;
|
||||
@@ -257,21 +253,26 @@ fn exec_resume_last_respects_cwd_filter_and_all_flag() -> anyhow::Result<()> {
|
||||
.success();
|
||||
|
||||
let sessions_dir = test.home_path().join("sessions");
|
||||
let path_a = find_session_file_containing_marker(&sessions_dir, &marker_a)
|
||||
find_session_file_containing_marker(&sessions_dir, &marker_a)
|
||||
.expect("no session file found for marker_a");
|
||||
let path_b = find_session_file_containing_marker(&sessions_dir, &marker_b)
|
||||
.expect("no session file found for marker_b");
|
||||
|
||||
// Files are ordered by `updated_at`, then by `uuid`.
|
||||
// We mutate the mtimes to ensure file_b is the newest file.
|
||||
let file_a = OpenOptions::new().write(true).open(&path_a)?;
|
||||
file_a.set_times(
|
||||
FileTimes::new().set_modified(SystemTime::UNIX_EPOCH + Duration::from_secs(1)),
|
||||
)?;
|
||||
let file_b = OpenOptions::new().write(true).open(&path_b)?;
|
||||
file_b.set_times(
|
||||
FileTimes::new().set_modified(SystemTime::UNIX_EPOCH + Duration::from_secs(2)),
|
||||
)?;
|
||||
// Make thread B deterministically newest according to rollout metadata.
|
||||
let session_id_b = extract_conversation_id(&path_b);
|
||||
let marker_b_touch = format!("resume-cwd-b-touch-{}", Uuid::new_v4());
|
||||
let prompt_b_touch = format!("echo {marker_b_touch}");
|
||||
test.cmd()
|
||||
.env("CODEX_RS_SSE_FIXTURE", &fixture)
|
||||
.env("OPENAI_BASE_URL", "http://unused.local")
|
||||
.arg("--skip-git-repo-check")
|
||||
.arg("-C")
|
||||
.arg(dir_b.path())
|
||||
.arg("resume")
|
||||
.arg(&session_id_b)
|
||||
.arg(&prompt_b_touch)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let marker_b2 = format!("resume-cwd-b-2-{}", Uuid::new_v4());
|
||||
let prompt_b2 = format!("echo {marker_b2}");
|
||||
@@ -312,8 +313,8 @@ fn exec_resume_last_respects_cwd_filter_and_all_flag() -> anyhow::Result<()> {
|
||||
let resumed_path_cwd = find_session_file_containing_marker(&sessions_dir, &marker_a2)
|
||||
.expect("no resumed session file containing marker_a2");
|
||||
assert_eq!(
|
||||
resumed_path_cwd, path_a,
|
||||
"resume --last should prefer sessions from the same cwd"
|
||||
resumed_path_cwd, path_b,
|
||||
"resume --last should prefer sessions whose latest turn context matches the current cwd"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -431,7 +431,7 @@ fn build_authorize_url(
|
||||
("redirect_uri".to_string(), redirect_uri.to_string()),
|
||||
(
|
||||
"scope".to_string(),
|
||||
"openid profile email offline_access".to_string(),
|
||||
"openid profile email offline_access api.model.audio.request".to_string(),
|
||||
),
|
||||
(
|
||||
"code_challenge".to_string(),
|
||||
|
||||
@@ -1961,6 +1961,9 @@ impl SessionSource {
|
||||
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_nickname, .. }) => {
|
||||
agent_nickname.clone()
|
||||
}
|
||||
SessionSource::SubAgent(SubAgentSource::MemoryConsolidation) => {
|
||||
Some("morpheus".to_string())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1970,6 +1973,9 @@ impl SessionSource {
|
||||
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_role, .. }) => {
|
||||
agent_role.clone()
|
||||
}
|
||||
SessionSource::SubAgent(SubAgentSource::MemoryConsolidation) => {
|
||||
Some("memory builder".to_string())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "shell-escalation",
|
||||
crate_name = "codex_shell_escalation",
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
[package]
|
||||
name = "codex-shell-escalation"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
codex-core = { workspace = true }
|
||||
codex-execpolicy = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
path-absolutize = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
socket2 = { workspace = true }
|
||||
tokio = { workspace = true, features = [
|
||||
"io-std",
|
||||
"macros",
|
||||
"process",
|
||||
"rt-multi-thread",
|
||||
"signal",
|
||||
] }
|
||||
tokio-util = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
@@ -0,0 +1,21 @@
|
||||
#[cfg(unix)]
|
||||
mod unix {
|
||||
mod escalate_client;
|
||||
mod escalate_protocol;
|
||||
mod escalate_server;
|
||||
mod escalation_policy;
|
||||
mod socket;
|
||||
mod stopwatch;
|
||||
|
||||
pub use self::escalate_client::run;
|
||||
pub use self::escalate_protocol::EscalateAction;
|
||||
pub use self::escalate_server::EscalationPolicyFactory;
|
||||
pub use self::escalate_server::ExecParams;
|
||||
pub use self::escalate_server::ExecResult;
|
||||
pub use self::escalate_server::run_escalate_server;
|
||||
pub use self::escalation_policy::EscalationPolicy;
|
||||
pub use self::stopwatch::Stopwatch;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub use unix::*;
|
||||
@@ -0,0 +1,71 @@
|
||||
use async_trait::async_trait;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::escalate_protocol::EscalateAction;
|
||||
use crate::escalation_policy::EscalationPolicy;
|
||||
use crate::stopwatch::Stopwatch;
|
||||
use crate::unix::escalate_server::EscalationPolicyFactory;
|
||||
use codex_execpolicy::Policy;
|
||||
|
||||
#[async_trait]
|
||||
pub trait ShellActionProvider: Send + Sync {
|
||||
async fn determine_action(
|
||||
&self,
|
||||
file: &Path,
|
||||
argv: &[String],
|
||||
workdir: &Path,
|
||||
stopwatch: &Stopwatch,
|
||||
) -> anyhow::Result<EscalateAction>;
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ShellPolicyFactory {
|
||||
provider: Arc<dyn ShellActionProvider>,
|
||||
}
|
||||
|
||||
impl ShellPolicyFactory {
|
||||
pub fn new<P>(provider: P) -> Self
|
||||
where
|
||||
P: ShellActionProvider + 'static,
|
||||
{
|
||||
Self {
|
||||
provider: Arc::new(provider),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_provider(provider: Arc<dyn ShellActionProvider>) -> Self {
|
||||
Self { provider }
|
||||
}
|
||||
}
|
||||
|
||||
struct ShellEscalationPolicy {
|
||||
provider: Arc<dyn ShellActionProvider>,
|
||||
stopwatch: Stopwatch,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EscalationPolicy for ShellEscalationPolicy {
|
||||
async fn determine_action(
|
||||
&self,
|
||||
file: &Path,
|
||||
argv: &[String],
|
||||
workdir: &Path,
|
||||
) -> anyhow::Result<EscalateAction> {
|
||||
self.provider
|
||||
.determine_action(file, argv, workdir, &self.stopwatch)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
impl EscalationPolicyFactory for ShellPolicyFactory {
|
||||
type Policy = ShellEscalationPolicy;
|
||||
|
||||
fn create_policy(&self, _policy: Arc<RwLock<Policy>>, stopwatch: Stopwatch) -> Self::Policy {
|
||||
ShellEscalationPolicy {
|
||||
provider: Arc::clone(&self.provider),
|
||||
stopwatch,
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-11
@@ -5,16 +5,16 @@ use std::os::fd::OwnedFd;
|
||||
|
||||
use anyhow::Context as _;
|
||||
|
||||
use crate::posix::escalate_protocol::ESCALATE_SOCKET_ENV_VAR;
|
||||
use crate::posix::escalate_protocol::EXEC_WRAPPER_ENV_VAR;
|
||||
use crate::posix::escalate_protocol::EscalateAction;
|
||||
use crate::posix::escalate_protocol::EscalateRequest;
|
||||
use crate::posix::escalate_protocol::EscalateResponse;
|
||||
use crate::posix::escalate_protocol::LEGACY_BASH_EXEC_WRAPPER_ENV_VAR;
|
||||
use crate::posix::escalate_protocol::SuperExecMessage;
|
||||
use crate::posix::escalate_protocol::SuperExecResult;
|
||||
use crate::posix::socket::AsyncDatagramSocket;
|
||||
use crate::posix::socket::AsyncSocket;
|
||||
use crate::unix::escalate_protocol::ESCALATE_SOCKET_ENV_VAR;
|
||||
use crate::unix::escalate_protocol::EXEC_WRAPPER_ENV_VAR;
|
||||
use crate::unix::escalate_protocol::EscalateAction;
|
||||
use crate::unix::escalate_protocol::EscalateRequest;
|
||||
use crate::unix::escalate_protocol::EscalateResponse;
|
||||
use crate::unix::escalate_protocol::LEGACY_BASH_EXEC_WRAPPER_ENV_VAR;
|
||||
use crate::unix::escalate_protocol::SuperExecMessage;
|
||||
use crate::unix::escalate_protocol::SuperExecResult;
|
||||
use crate::unix::socket::AsyncDatagramSocket;
|
||||
use crate::unix::socket::AsyncSocket;
|
||||
|
||||
fn get_escalate_client() -> anyhow::Result<AsyncDatagramSocket> {
|
||||
// TODO: we should defensively require only calling this once, since AsyncSocket will take ownership of the fd.
|
||||
@@ -27,7 +27,7 @@ fn get_escalate_client() -> anyhow::Result<AsyncDatagramSocket> {
|
||||
Ok(unsafe { AsyncDatagramSocket::from_raw_fd(client_fd) }?)
|
||||
}
|
||||
|
||||
pub(crate) async fn run(file: String, argv: Vec<String>) -> anyhow::Result<i32> {
|
||||
pub async fn run(file: String, argv: Vec<String>) -> anyhow::Result<i32> {
|
||||
let handshake_client = get_escalate_client()?;
|
||||
let (server, client) = AsyncSocket::pair()?;
|
||||
const HANDSHAKE_MESSAGE: [u8; 1] = [0];
|
||||
+15
-15
@@ -6,33 +6,33 @@ use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
/// 'exec-server escalate' reads this to find the inherited FD for the escalate socket.
|
||||
pub(super) const ESCALATE_SOCKET_ENV_VAR: &str = "CODEX_ESCALATE_SOCKET";
|
||||
pub const ESCALATE_SOCKET_ENV_VAR: &str = "CODEX_ESCALATE_SOCKET";
|
||||
|
||||
/// Patched shells use this to wrap exec() calls.
|
||||
pub(super) const EXEC_WRAPPER_ENV_VAR: &str = "EXEC_WRAPPER";
|
||||
pub const EXEC_WRAPPER_ENV_VAR: &str = "EXEC_WRAPPER";
|
||||
|
||||
/// Compatibility alias for older patched bash builds.
|
||||
pub(super) const LEGACY_BASH_EXEC_WRAPPER_ENV_VAR: &str = "BASH_EXEC_WRAPPER";
|
||||
pub const LEGACY_BASH_EXEC_WRAPPER_ENV_VAR: &str = "BASH_EXEC_WRAPPER";
|
||||
|
||||
/// The client sends this to the server to request an exec() call.
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
pub(super) struct EscalateRequest {
|
||||
pub struct EscalateRequest {
|
||||
/// The absolute path to the executable to run, i.e. the first arg to exec.
|
||||
pub(super) file: PathBuf,
|
||||
pub file: PathBuf,
|
||||
/// The argv, including the program name (argv[0]).
|
||||
pub(super) argv: Vec<String>,
|
||||
pub(super) workdir: PathBuf,
|
||||
pub(super) env: HashMap<String, String>,
|
||||
pub argv: Vec<String>,
|
||||
pub workdir: PathBuf,
|
||||
pub env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// The server sends this to the client to respond to an exec() request.
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
pub(super) struct EscalateResponse {
|
||||
pub(super) action: EscalateAction,
|
||||
pub struct EscalateResponse {
|
||||
pub action: EscalateAction,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
pub(super) enum EscalateAction {
|
||||
pub enum EscalateAction {
|
||||
/// The command should be run directly by the client.
|
||||
Run,
|
||||
/// The command should be escalated to the server for execution.
|
||||
@@ -43,12 +43,12 @@ pub(super) enum EscalateAction {
|
||||
|
||||
/// The client sends this to the server to forward its open FDs.
|
||||
#[derive(Clone, Serialize, Deserialize, Debug)]
|
||||
pub(super) struct SuperExecMessage {
|
||||
pub(super) fds: Vec<RawFd>,
|
||||
pub struct SuperExecMessage {
|
||||
pub fds: Vec<RawFd>,
|
||||
}
|
||||
|
||||
/// The server responds when the exec()'d command has exited.
|
||||
#[derive(Clone, Serialize, Deserialize, Debug)]
|
||||
pub(super) struct SuperExecResult {
|
||||
pub(super) exit_code: i32,
|
||||
pub struct SuperExecResult {
|
||||
pub exit_code: i32,
|
||||
}
|
||||
+77
-36
@@ -1,35 +1,54 @@
|
||||
use std::collections::HashMap;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context as _;
|
||||
use path_absolutize::Absolutize as _;
|
||||
|
||||
use codex_core::SandboxState;
|
||||
use codex_core::exec::process_exec_tool_call;
|
||||
use codex_core::sandboxing::SandboxPermissions;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_execpolicy::Policy;
|
||||
use path_absolutize::Absolutize as _;
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::posix::escalate_protocol::ESCALATE_SOCKET_ENV_VAR;
|
||||
use crate::posix::escalate_protocol::EXEC_WRAPPER_ENV_VAR;
|
||||
use crate::posix::escalate_protocol::EscalateAction;
|
||||
use crate::posix::escalate_protocol::EscalateRequest;
|
||||
use crate::posix::escalate_protocol::EscalateResponse;
|
||||
use crate::posix::escalate_protocol::LEGACY_BASH_EXEC_WRAPPER_ENV_VAR;
|
||||
use crate::posix::escalate_protocol::SuperExecMessage;
|
||||
use crate::posix::escalate_protocol::SuperExecResult;
|
||||
use crate::posix::escalation_policy::EscalationPolicy;
|
||||
use crate::posix::mcp::ExecParams;
|
||||
use crate::posix::socket::AsyncDatagramSocket;
|
||||
use crate::posix::socket::AsyncSocket;
|
||||
use codex_core::exec::ExecExpiration;
|
||||
use crate::unix::escalate_protocol::ESCALATE_SOCKET_ENV_VAR;
|
||||
use crate::unix::escalate_protocol::EXEC_WRAPPER_ENV_VAR;
|
||||
use crate::unix::escalate_protocol::EscalateAction;
|
||||
use crate::unix::escalate_protocol::EscalateRequest;
|
||||
use crate::unix::escalate_protocol::EscalateResponse;
|
||||
use crate::unix::escalate_protocol::LEGACY_BASH_EXEC_WRAPPER_ENV_VAR;
|
||||
use crate::unix::escalate_protocol::SuperExecMessage;
|
||||
use crate::unix::escalate_protocol::SuperExecResult;
|
||||
use crate::unix::escalation_policy::EscalationPolicy;
|
||||
use crate::unix::socket::AsyncDatagramSocket;
|
||||
use crate::unix::socket::AsyncSocket;
|
||||
use crate::unix::stopwatch::Stopwatch;
|
||||
|
||||
pub(crate) struct EscalateServer {
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||
pub struct ExecParams {
|
||||
/// The bash string to execute.
|
||||
pub command: String,
|
||||
/// The working directory to execute the command in. Must be an absolute path.
|
||||
pub workdir: String,
|
||||
/// The timeout for the command in milliseconds.
|
||||
pub timeout_ms: Option<u64>,
|
||||
/// Launch Bash with -lc instead of -c: defaults to true.
|
||||
pub login: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ExecResult {
|
||||
pub exit_code: i32,
|
||||
pub output: String,
|
||||
pub duration: Duration,
|
||||
pub timed_out: bool,
|
||||
}
|
||||
|
||||
#[allow(clippy::module_name_repetitions)]
|
||||
pub struct EscalateServer {
|
||||
bash_path: PathBuf,
|
||||
execve_wrapper: PathBuf,
|
||||
policy: Arc<dyn EscalationPolicy>,
|
||||
@@ -78,7 +97,7 @@ impl EscalateServer {
|
||||
timeout_ms: _,
|
||||
login,
|
||||
} = params;
|
||||
let result = process_exec_tool_call(
|
||||
let result = codex_core::exec::process_exec_tool_call(
|
||||
codex_core::exec::ExecParams {
|
||||
command: vec![
|
||||
self.bash_path.to_string_lossy().to_string(),
|
||||
@@ -90,11 +109,11 @@ impl EscalateServer {
|
||||
command,
|
||||
],
|
||||
cwd: PathBuf::from(&workdir),
|
||||
expiration: ExecExpiration::Cancellation(cancel_rx),
|
||||
expiration: codex_core::exec::ExecExpiration::Cancellation(cancel_rx),
|
||||
env,
|
||||
network: None,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
sandbox_permissions: codex_core::sandboxing::SandboxPermissions::UseDefault,
|
||||
windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled,
|
||||
justification: None,
|
||||
arg0: None,
|
||||
},
|
||||
@@ -106,16 +125,45 @@ impl EscalateServer {
|
||||
)
|
||||
.await?;
|
||||
escalate_task.abort();
|
||||
let result = ExecResult {
|
||||
|
||||
Ok(ExecResult {
|
||||
exit_code: result.exit_code,
|
||||
output: result.aggregated_output.text,
|
||||
duration: result.duration,
|
||||
timed_out: result.timed_out,
|
||||
};
|
||||
Ok(result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Factory for creating escalation policy instances for a single shell run.
|
||||
pub trait EscalationPolicyFactory {
|
||||
type Policy: EscalationPolicy + Send + Sync + 'static;
|
||||
|
||||
fn create_policy(&self, policy: Arc<RwLock<Policy>>, stopwatch: Stopwatch) -> Self::Policy;
|
||||
}
|
||||
|
||||
pub async fn run_escalate_server(
|
||||
exec_params: ExecParams,
|
||||
sandbox_state: &SandboxState,
|
||||
shell_program: impl AsRef<Path>,
|
||||
execve_wrapper: impl AsRef<Path>,
|
||||
policy: Arc<RwLock<Policy>>,
|
||||
escalation_policy_factory: impl EscalationPolicyFactory,
|
||||
effective_timeout: Duration,
|
||||
) -> anyhow::Result<ExecResult> {
|
||||
let stopwatch = Stopwatch::new(effective_timeout);
|
||||
let cancel_token = stopwatch.cancellation_token();
|
||||
let escalate_server = EscalateServer::new(
|
||||
shell_program.as_ref().to_path_buf(),
|
||||
execve_wrapper.as_ref().to_path_buf(),
|
||||
escalation_policy_factory.create_policy(policy, stopwatch),
|
||||
);
|
||||
|
||||
escalate_server
|
||||
.exec(exec_params, cancel_token, sandbox_state)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn escalate_task(
|
||||
socket: AsyncDatagramSocket,
|
||||
policy: Arc<dyn EscalationPolicy>,
|
||||
@@ -136,14 +184,6 @@ async fn escalate_task(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ExecResult {
|
||||
pub(crate) exit_code: i32,
|
||||
pub(crate) output: String,
|
||||
pub(crate) duration: Duration,
|
||||
pub(crate) timed_out: bool,
|
||||
}
|
||||
|
||||
async fn handle_escalate_session_with_policy(
|
||||
socket: AsyncSocket,
|
||||
policy: Arc<dyn EscalationPolicy>,
|
||||
@@ -158,7 +198,8 @@ async fn handle_escalate_session_with_policy(
|
||||
let workdir = PathBuf::from(&workdir).absolutize()?.into_owned();
|
||||
let action = policy
|
||||
.determine_action(file.as_path(), &argv, &workdir)
|
||||
.await?;
|
||||
.await
|
||||
.context("failed to determine escalation action")?;
|
||||
|
||||
tracing::debug!("decided {action:?} for {file:?} {argv:?} {workdir:?}");
|
||||
|
||||
@@ -253,7 +294,7 @@ mod tests {
|
||||
_file: &Path,
|
||||
_argv: &[String],
|
||||
_workdir: &Path,
|
||||
) -> Result<EscalateAction, rmcp::ErrorData> {
|
||||
) -> anyhow::Result<EscalateAction> {
|
||||
Ok(self.action.clone())
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -1,14 +1,14 @@
|
||||
use std::path::Path;
|
||||
|
||||
use crate::posix::escalate_protocol::EscalateAction;
|
||||
use crate::unix::escalate_protocol::EscalateAction;
|
||||
|
||||
/// Decides what action to take in response to an execve request from a client.
|
||||
#[async_trait::async_trait]
|
||||
pub(crate) trait EscalationPolicy: Send + Sync {
|
||||
pub trait EscalationPolicy: Send + Sync {
|
||||
async fn determine_action(
|
||||
&self,
|
||||
file: &Path,
|
||||
argv: &[String],
|
||||
workdir: &Path,
|
||||
) -> Result<EscalateAction, rmcp::ErrorData>;
|
||||
) -> anyhow::Result<EscalateAction>;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub mod escalate_client;
|
||||
pub mod escalate_protocol;
|
||||
pub mod escalate_server;
|
||||
pub mod escalation_policy;
|
||||
pub mod socket;
|
||||
pub mod core_shell_escalation;
|
||||
pub mod stopwatch;
|
||||
+4
-4
@@ -8,7 +8,7 @@ use tokio::sync::Notify;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Stopwatch {
|
||||
pub struct Stopwatch {
|
||||
limit: Duration,
|
||||
inner: Arc<Mutex<StopwatchState>>,
|
||||
notify: Arc<Notify>,
|
||||
@@ -22,7 +22,7 @@ struct StopwatchState {
|
||||
}
|
||||
|
||||
impl Stopwatch {
|
||||
pub(crate) fn new(limit: Duration) -> Self {
|
||||
pub fn new(limit: Duration) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(StopwatchState {
|
||||
elapsed: Duration::ZERO,
|
||||
@@ -34,7 +34,7 @@ impl Stopwatch {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn cancellation_token(&self) -> CancellationToken {
|
||||
pub fn cancellation_token(&self) -> CancellationToken {
|
||||
let limit = self.limit;
|
||||
let token = CancellationToken::new();
|
||||
let cancel = token.clone();
|
||||
@@ -80,7 +80,7 @@ impl Stopwatch {
|
||||
/// resumes automatically when the future completes. Nested/overlapping
|
||||
/// calls are reference-counted so the stopwatch only resumes when every
|
||||
/// pause is lifted.
|
||||
pub(crate) async fn pause_for<F, T>(&self, fut: F) -> T
|
||||
pub async fn pause_for<F, T>(&self, fut: F) -> T
|
||||
where
|
||||
F: Future<Output = T>,
|
||||
{
|
||||
@@ -13,10 +13,12 @@ name = "codex_tui"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
default = ["voice-input"]
|
||||
# Enable vt100-based tests (emulator) when running with `--features vt100-tests`.
|
||||
vt100-tests = []
|
||||
# Gate verbose debug logging inside the TUI implementation.
|
||||
debug-logs = []
|
||||
voice-input = ["dep:cpal", "dep:hound"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -68,7 +70,7 @@ ratatui = { workspace = true, features = [
|
||||
] }
|
||||
ratatui-macros = { workspace = true }
|
||||
regex-lite = { workspace = true }
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
reqwest = { workspace = true, features = ["json", "multipart"] }
|
||||
rmcp = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true, features = ["preserve_order"] }
|
||||
@@ -104,6 +106,10 @@ uuid = { workspace = true }
|
||||
codex-windows-sandbox = { workspace = true }
|
||||
tokio-util = { workspace = true, features = ["time"] }
|
||||
|
||||
[target.'cfg(not(target_os = "linux"))'.dependencies]
|
||||
cpal = { version = "0.15", optional = true }
|
||||
hound = { version = "3.5", optional = true }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = { workspace = true }
|
||||
|
||||
|
||||
+206
-105
@@ -23,6 +23,10 @@ use crate::history_cell::UpdateAvailableHistoryCell;
|
||||
use crate::model_migration::ModelMigrationOutcome;
|
||||
use crate::model_migration::migration_copy_for_models;
|
||||
use crate::model_migration::run_model_migration_prompt;
|
||||
use crate::multi_agents::AgentPickerThreadEntry;
|
||||
use crate::multi_agents::agent_picker_status_dot_spans;
|
||||
use crate::multi_agents::format_agent_picker_item_name;
|
||||
use crate::multi_agents::sort_agent_picker_threads;
|
||||
use crate::pager_overlay::Overlay;
|
||||
use crate::render::highlight::highlight_bash_to_lines;
|
||||
use crate::render::renderable::Renderable;
|
||||
@@ -576,6 +580,7 @@ pub(crate) struct App {
|
||||
windows_sandbox: WindowsSandboxState,
|
||||
|
||||
thread_event_channels: HashMap<ThreadId, ThreadEventChannel>,
|
||||
agent_picker_threads: HashMap<ThreadId, AgentPickerThreadEntry>,
|
||||
active_thread_id: Option<ThreadId>,
|
||||
active_thread_rx: Option<mpsc::Receiver<Event>>,
|
||||
primary_thread_id: Option<ThreadId>,
|
||||
@@ -699,27 +704,18 @@ impl App {
|
||||
self.clear_ui_header_lines_with_version(width, CODEX_CLI_VERSION)
|
||||
}
|
||||
|
||||
fn clear_terminal_ui(&mut self, tui: &mut tui::Tui) -> Result<()> {
|
||||
fn clear_terminal_ui(&mut self, tui: &mut tui::Tui, redraw_header: bool) -> Result<()> {
|
||||
let is_alt_screen_active = tui.is_alt_screen_active();
|
||||
let use_apple_terminal_clear_workaround = !is_alt_screen_active
|
||||
&& matches!(
|
||||
codex_core::terminal::terminal_info().name,
|
||||
codex_core::terminal::TerminalName::AppleTerminal
|
||||
);
|
||||
|
||||
// Drop queued history insertions so stale transcript lines cannot be flushed after /clear.
|
||||
tui.clear_pending_history_lines();
|
||||
|
||||
if is_alt_screen_active {
|
||||
tui.terminal.clear_visible_screen()?;
|
||||
} else if use_apple_terminal_clear_workaround {
|
||||
// Terminal.app can leave mixed old/new glyphs behind when we purge + clear.
|
||||
// Use a stricter ANSI reset, then redraw only a fresh session header box instead of
|
||||
// replaying the initialization transcript preamble.
|
||||
tui.terminal.clear_scrollback_and_visible_screen_ansi()?;
|
||||
} else {
|
||||
tui.terminal.clear_scrollback()?;
|
||||
tui.terminal.clear_visible_screen()?;
|
||||
// Some terminals (Terminal.app, Warp) do not reliably drop scrollback when purge and
|
||||
// clear are emitted as separate backend commands. Prefer a single ANSI sequence.
|
||||
tui.terminal.clear_scrollback_and_visible_screen_ansi()?;
|
||||
}
|
||||
|
||||
let mut area = tui.terminal.viewport_area;
|
||||
@@ -731,11 +727,13 @@ impl App {
|
||||
}
|
||||
self.has_emitted_history_lines = false;
|
||||
|
||||
let width = tui.terminal.last_known_screen_size.width;
|
||||
let header_lines = self.clear_ui_header_lines(width);
|
||||
if !header_lines.is_empty() {
|
||||
tui.insert_history_lines(header_lines);
|
||||
self.has_emitted_history_lines = true;
|
||||
if redraw_header {
|
||||
let width = tui.terminal.last_known_screen_size.width;
|
||||
let header_lines = self.clear_ui_header_lines(width);
|
||||
if !header_lines.is_empty() {
|
||||
tui.insert_history_lines(header_lines);
|
||||
self.has_emitted_history_lines = true;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -868,50 +866,55 @@ impl App {
|
||||
|
||||
async fn open_agent_picker(&mut self) {
|
||||
let thread_ids: Vec<ThreadId> = self.thread_event_channels.keys().cloned().collect();
|
||||
let mut agent_threads = Vec::new();
|
||||
for thread_id in thread_ids {
|
||||
match self.server.get_thread(thread_id).await {
|
||||
Ok(thread) => {
|
||||
let session_source = thread.config_snapshot().await.session_source;
|
||||
agent_threads.push((
|
||||
self.upsert_agent_picker_thread(
|
||||
thread_id,
|
||||
session_source.get_nickname(),
|
||||
session_source.get_agent_role(),
|
||||
));
|
||||
false,
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
self.thread_event_channels.remove(&thread_id);
|
||||
self.mark_agent_picker_thread_closed(thread_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if agent_threads.is_empty() {
|
||||
if self.agent_picker_threads.is_empty() {
|
||||
self.chat_widget
|
||||
.add_info_message("No agents available yet.".to_string(), None);
|
||||
return;
|
||||
}
|
||||
|
||||
agent_threads.sort_by(|(left, ..), (right, ..)| left.to_string().cmp(&right.to_string()));
|
||||
let mut agent_threads: Vec<(ThreadId, AgentPickerThreadEntry)> = self
|
||||
.agent_picker_threads
|
||||
.iter()
|
||||
.map(|(thread_id, entry)| (*thread_id, entry.clone()))
|
||||
.collect();
|
||||
sort_agent_picker_threads(&mut agent_threads);
|
||||
|
||||
let mut initial_selected_idx = None;
|
||||
let items: Vec<SelectionItem> = agent_threads
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, (thread_id, agent_nickname, agent_role))| {
|
||||
.map(|(idx, (thread_id, entry))| {
|
||||
if self.active_thread_id == Some(*thread_id) {
|
||||
initial_selected_idx = Some(idx);
|
||||
}
|
||||
let id = *thread_id;
|
||||
let is_primary = self.primary_thread_id == Some(*thread_id);
|
||||
let name = format_agent_picker_item_name(
|
||||
*thread_id,
|
||||
agent_nickname.as_deref(),
|
||||
agent_role.as_deref(),
|
||||
entry.agent_nickname.as_deref(),
|
||||
entry.agent_role.as_deref(),
|
||||
is_primary,
|
||||
);
|
||||
let uuid = thread_id.to_string();
|
||||
SelectionItem {
|
||||
name: name.clone(),
|
||||
name_prefix_spans: agent_picker_status_dot_spans(entry.is_closed),
|
||||
description: Some(uuid.clone()),
|
||||
is_current: self.active_thread_id == Some(*thread_id),
|
||||
actions: vec![Box::new(move |tx| {
|
||||
@@ -934,20 +937,51 @@ impl App {
|
||||
});
|
||||
}
|
||||
|
||||
fn upsert_agent_picker_thread(
|
||||
&mut self,
|
||||
thread_id: ThreadId,
|
||||
agent_nickname: Option<String>,
|
||||
agent_role: Option<String>,
|
||||
is_closed: bool,
|
||||
) {
|
||||
self.agent_picker_threads.insert(
|
||||
thread_id,
|
||||
AgentPickerThreadEntry {
|
||||
agent_nickname,
|
||||
agent_role,
|
||||
is_closed,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn mark_agent_picker_thread_closed(&mut self, thread_id: ThreadId) {
|
||||
if let Some(entry) = self.agent_picker_threads.get_mut(&thread_id) {
|
||||
entry.is_closed = true;
|
||||
} else {
|
||||
self.upsert_agent_picker_thread(thread_id, None, None, true);
|
||||
}
|
||||
}
|
||||
|
||||
async fn select_agent_thread(&mut self, tui: &mut tui::Tui, thread_id: ThreadId) -> Result<()> {
|
||||
if self.active_thread_id == Some(thread_id) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let thread = match self.server.get_thread(thread_id).await {
|
||||
Ok(thread) => thread,
|
||||
let live_thread = match self.server.get_thread(thread_id).await {
|
||||
Ok(thread) => Some(thread),
|
||||
Err(err) => {
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to attach to agent thread {thread_id}: {err}"
|
||||
));
|
||||
return Ok(());
|
||||
if self.thread_event_channels.contains_key(&thread_id) {
|
||||
self.mark_agent_picker_thread_closed(thread_id);
|
||||
None
|
||||
} else {
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to attach to agent thread {thread_id}: {err}"
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
};
|
||||
let is_replay_only = live_thread.is_none();
|
||||
|
||||
let previous_thread_id = self.active_thread_id;
|
||||
self.store_active_thread_receiver().await;
|
||||
@@ -965,11 +999,22 @@ impl App {
|
||||
self.active_thread_rx = Some(receiver);
|
||||
|
||||
let init = self.chatwidget_init_for_forked_or_resumed_thread(tui, self.config.clone());
|
||||
let codex_op_tx = crate::chatwidget::spawn_op_forwarder(thread);
|
||||
let codex_op_tx = if let Some(thread) = live_thread {
|
||||
crate::chatwidget::spawn_op_forwarder(thread)
|
||||
} else {
|
||||
let (tx, _rx) = unbounded_channel();
|
||||
tx
|
||||
};
|
||||
self.chat_widget = ChatWidget::new_with_op_sender(init, codex_op_tx);
|
||||
|
||||
self.reset_for_thread_switch(tui)?;
|
||||
self.replay_thread_snapshot(snapshot);
|
||||
if is_replay_only {
|
||||
self.chat_widget.add_info_message(
|
||||
format!("Agent thread {thread_id} is closed. Replaying saved transcript."),
|
||||
None,
|
||||
);
|
||||
}
|
||||
self.drain_active_thread_events(tui).await?;
|
||||
|
||||
Ok(())
|
||||
@@ -989,12 +1034,55 @@ impl App {
|
||||
|
||||
fn reset_thread_event_state(&mut self) {
|
||||
self.thread_event_channels.clear();
|
||||
self.agent_picker_threads.clear();
|
||||
self.active_thread_id = None;
|
||||
self.active_thread_rx = None;
|
||||
self.primary_thread_id = None;
|
||||
self.pending_primary_events.clear();
|
||||
}
|
||||
|
||||
async fn start_fresh_session_with_summary_hint(&mut self, tui: &mut tui::Tui) {
|
||||
// Start a fresh in-memory session while preserving resumability via persisted rollout
|
||||
// history.
|
||||
let model = self.chat_widget.current_model().to_string();
|
||||
let summary = session_summary(
|
||||
self.chat_widget.token_usage(),
|
||||
self.chat_widget.thread_id(),
|
||||
self.chat_widget.thread_name(),
|
||||
);
|
||||
self.shutdown_current_thread().await;
|
||||
if let Err(err) = self.server.remove_and_close_all_threads().await {
|
||||
tracing::warn!(error = %err, "failed to close all threads");
|
||||
}
|
||||
let init = crate::chatwidget::ChatWidgetInit {
|
||||
config: self.config.clone(),
|
||||
frame_requester: tui.frame_requester(),
|
||||
app_event_tx: self.app_event_tx.clone(),
|
||||
// New sessions start without prefilled message content.
|
||||
initial_user_message: None,
|
||||
enhanced_keys_supported: self.enhanced_keys_supported,
|
||||
auth_manager: self.auth_manager.clone(),
|
||||
models_manager: self.server.get_models_manager(),
|
||||
feedback: self.feedback.clone(),
|
||||
is_first_run: false,
|
||||
feedback_audience: self.feedback_audience,
|
||||
model: Some(model),
|
||||
status_line_invalid_items_warned: self.status_line_invalid_items_warned.clone(),
|
||||
otel_manager: self.otel_manager.clone(),
|
||||
};
|
||||
self.chat_widget = ChatWidget::new(init, self.server.clone());
|
||||
self.reset_thread_event_state();
|
||||
if let Some(summary) = summary {
|
||||
let mut lines: Vec<Line<'static>> = vec![summary.usage_line.clone().into()];
|
||||
if let Some(command) = summary.resume_command {
|
||||
let spans = vec!["To continue this session, run ".into(), command.cyan()];
|
||||
lines.push(spans.into());
|
||||
}
|
||||
self.chat_widget.add_plain_history_lines(lines);
|
||||
}
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
async fn drain_active_thread_events(&mut self, tui: &mut tui::Tui) -> Result<()> {
|
||||
let Some(mut rx) = self.active_thread_rx.take() else {
|
||||
return Ok(());
|
||||
@@ -1294,6 +1382,7 @@ impl App {
|
||||
pending_shutdown_exit_thread_id: None,
|
||||
windows_sandbox: WindowsSandboxState::default(),
|
||||
thread_event_channels: HashMap::new(),
|
||||
agent_picker_threads: HashMap::new(),
|
||||
active_thread_id: None,
|
||||
active_thread_rx: None,
|
||||
primary_thread_id: None,
|
||||
@@ -1458,6 +1547,8 @@ impl App {
|
||||
{
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
// Allow widgets to process any pending timers before rendering.
|
||||
self.chat_widget.pre_draw_tick();
|
||||
tui.draw(
|
||||
self.chat_widget.desired_height(tui.terminal.size()?.width),
|
||||
|frame| {
|
||||
@@ -1481,47 +1572,18 @@ impl App {
|
||||
async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result<AppRunControl> {
|
||||
match event {
|
||||
AppEvent::NewSession => {
|
||||
let model = self.chat_widget.current_model().to_string();
|
||||
let summary = session_summary(
|
||||
self.chat_widget.token_usage(),
|
||||
self.chat_widget.thread_id(),
|
||||
self.chat_widget.thread_name(),
|
||||
);
|
||||
self.shutdown_current_thread().await;
|
||||
if let Err(err) = self.server.remove_and_close_all_threads().await {
|
||||
tracing::warn!(error = %err, "failed to close all threads");
|
||||
}
|
||||
let init = crate::chatwidget::ChatWidgetInit {
|
||||
config: self.config.clone(),
|
||||
frame_requester: tui.frame_requester(),
|
||||
app_event_tx: self.app_event_tx.clone(),
|
||||
// New sessions start without prefilled message content.
|
||||
initial_user_message: None,
|
||||
enhanced_keys_supported: self.enhanced_keys_supported,
|
||||
auth_manager: self.auth_manager.clone(),
|
||||
models_manager: self.server.get_models_manager(),
|
||||
feedback: self.feedback.clone(),
|
||||
is_first_run: false,
|
||||
feedback_audience: self.feedback_audience,
|
||||
model: Some(model),
|
||||
status_line_invalid_items_warned: self.status_line_invalid_items_warned.clone(),
|
||||
otel_manager: self.otel_manager.clone(),
|
||||
};
|
||||
self.chat_widget = ChatWidget::new(init, self.server.clone());
|
||||
self.reset_thread_event_state();
|
||||
if let Some(summary) = summary {
|
||||
let mut lines: Vec<Line<'static>> = vec![summary.usage_line.clone().into()];
|
||||
if let Some(command) = summary.resume_command {
|
||||
let spans = vec!["To continue this session, run ".into(), command.cyan()];
|
||||
lines.push(spans.into());
|
||||
}
|
||||
self.chat_widget.add_plain_history_lines(lines);
|
||||
}
|
||||
tui.frame_requester().schedule_frame();
|
||||
self.start_fresh_session_with_summary_hint(tui).await;
|
||||
}
|
||||
AppEvent::ClearUi => {
|
||||
self.clear_terminal_ui(tui)?;
|
||||
tui.frame_requester().schedule_frame();
|
||||
self.clear_terminal_ui(tui, false)?;
|
||||
self.overlay = None;
|
||||
self.transcript_cells.clear();
|
||||
self.deferred_history_lines.clear();
|
||||
self.has_emitted_history_lines = false;
|
||||
self.backtrack = BacktrackState::default();
|
||||
self.backtrack_render_pending = false;
|
||||
|
||||
self.start_fresh_session_with_summary_hint(tui).await;
|
||||
}
|
||||
AppEvent::OpenResumePicker => {
|
||||
match crate::resume_picker::run_resume_picker(tui, &self.config, false).await? {
|
||||
@@ -2643,6 +2705,22 @@ impl App {
|
||||
));
|
||||
}
|
||||
},
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
AppEvent::TranscriptionComplete { id, text } => {
|
||||
self.chat_widget.replace_transcription(&id, &text);
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
AppEvent::TranscriptionFailed { id, error: _ } => {
|
||||
self.chat_widget.remove_transcription_placeholder(&id);
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
AppEvent::UpdateRecordingMeter { id, text } => {
|
||||
// Update in place to preserve the element id for subsequent frames.
|
||||
let updated = self.chat_widget.update_transcription_in_place(&id, &text);
|
||||
if updated {
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
}
|
||||
AppEvent::StatusLineSetup { items } => {
|
||||
let ids = items.iter().map(ToString::to_string).collect::<Vec<_>>();
|
||||
let edit = codex_core::config::edit::status_line_items_edit(&ids);
|
||||
@@ -2752,7 +2830,7 @@ impl App {
|
||||
if let Some((closed_thread_id, primary_thread_id)) =
|
||||
self.active_non_primary_shutdown_target(&event.msg)
|
||||
{
|
||||
self.thread_event_channels.remove(&closed_thread_id);
|
||||
self.mark_agent_picker_thread_closed(closed_thread_id);
|
||||
self.select_agent_thread(tui, primary_thread_id).await?;
|
||||
if self.active_thread_id == Some(primary_thread_id) {
|
||||
self.chat_widget.add_info_message(
|
||||
@@ -2794,6 +2872,12 @@ impl App {
|
||||
}
|
||||
};
|
||||
let config_snapshot = thread.config_snapshot().await;
|
||||
self.upsert_agent_picker_thread(
|
||||
thread_id,
|
||||
config_snapshot.session_source.get_nickname(),
|
||||
config_snapshot.session_source.get_agent_role(),
|
||||
false,
|
||||
);
|
||||
let event = Event {
|
||||
id: String::new(),
|
||||
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||
@@ -3040,7 +3124,7 @@ impl App {
|
||||
self.chat_widget.handle_key_event(key_event);
|
||||
}
|
||||
_ => {
|
||||
// Ignore Release key events.
|
||||
self.chat_widget.handle_key_event(key_event);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -3078,28 +3162,6 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_agent_picker_item_name(
|
||||
_thread_id: ThreadId,
|
||||
agent_nickname: Option<&str>,
|
||||
agent_role: Option<&str>,
|
||||
is_primary: bool,
|
||||
) -> String {
|
||||
if is_primary {
|
||||
return "Main [default]".to_string();
|
||||
}
|
||||
|
||||
let agent_nickname = agent_nickname
|
||||
.map(str::trim)
|
||||
.filter(|nickname| !nickname.is_empty());
|
||||
let agent_role = agent_role.map(str::trim).filter(|role| !role.is_empty());
|
||||
match (agent_nickname, agent_role) {
|
||||
(Some(agent_nickname), Some(agent_role)) => format!("{agent_nickname} [{agent_role}]"),
|
||||
(Some(agent_nickname), None) => agent_nickname.to_string(),
|
||||
(None, Some(agent_role)) => format!("[{agent_role}]"),
|
||||
(None, None) => "Agent".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -3268,7 +3330,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_agent_picker_prunes_missing_threads() -> Result<()> {
|
||||
async fn open_agent_picker_keeps_missing_threads_for_replay() -> Result<()> {
|
||||
let mut app = make_test_app().await;
|
||||
let thread_id = ThreadId::new();
|
||||
app.thread_event_channels
|
||||
@@ -3276,7 +3338,44 @@ mod tests {
|
||||
|
||||
app.open_agent_picker().await;
|
||||
|
||||
assert_eq!(app.thread_event_channels.contains_key(&thread_id), false);
|
||||
assert_eq!(app.thread_event_channels.contains_key(&thread_id), true);
|
||||
assert_eq!(
|
||||
app.agent_picker_threads.get(&thread_id),
|
||||
Some(&AgentPickerThreadEntry {
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
is_closed: true,
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_agent_picker_keeps_cached_closed_threads() -> Result<()> {
|
||||
let mut app = make_test_app().await;
|
||||
let thread_id = ThreadId::new();
|
||||
app.thread_event_channels
|
||||
.insert(thread_id, ThreadEventChannel::new(1));
|
||||
app.agent_picker_threads.insert(
|
||||
thread_id,
|
||||
AgentPickerThreadEntry {
|
||||
agent_nickname: Some("Robie".to_string()),
|
||||
agent_role: Some("explorer".to_string()),
|
||||
is_closed: false,
|
||||
},
|
||||
);
|
||||
|
||||
app.open_agent_picker().await;
|
||||
|
||||
assert_eq!(app.thread_event_channels.contains_key(&thread_id), true);
|
||||
assert_eq!(
|
||||
app.agent_picker_threads.get(&thread_id),
|
||||
Some(&AgentPickerThreadEntry {
|
||||
agent_nickname: Some("Robie".to_string()),
|
||||
agent_role: Some("explorer".to_string()),
|
||||
is_closed: true,
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3287,27 +3386,27 @@ mod tests {
|
||||
let snapshot = [
|
||||
format!(
|
||||
"{} | {}",
|
||||
format_agent_picker_item_name(thread_id, Some("Robie"), Some("explorer"), true),
|
||||
format_agent_picker_item_name(Some("Robie"), Some("explorer"), true),
|
||||
thread_id
|
||||
),
|
||||
format!(
|
||||
"{} | {}",
|
||||
format_agent_picker_item_name(thread_id, Some("Robie"), Some("explorer"), false),
|
||||
format_agent_picker_item_name(Some("Robie"), Some("explorer"), false),
|
||||
thread_id
|
||||
),
|
||||
format!(
|
||||
"{} | {}",
|
||||
format_agent_picker_item_name(thread_id, Some("Robie"), None, false),
|
||||
format_agent_picker_item_name(Some("Robie"), None, false),
|
||||
thread_id
|
||||
),
|
||||
format!(
|
||||
"{} | {}",
|
||||
format_agent_picker_item_name(thread_id, None, Some("explorer"), false),
|
||||
format_agent_picker_item_name(None, Some("explorer"), false),
|
||||
thread_id
|
||||
),
|
||||
format!(
|
||||
"{} | {}",
|
||||
format_agent_picker_item_name(thread_id, None, None, false),
|
||||
format_agent_picker_item_name(None, None, false),
|
||||
thread_id
|
||||
),
|
||||
]
|
||||
@@ -3548,6 +3647,7 @@ mod tests {
|
||||
pending_shutdown_exit_thread_id: None,
|
||||
windows_sandbox: WindowsSandboxState::default(),
|
||||
thread_event_channels: HashMap::new(),
|
||||
agent_picker_threads: HashMap::new(),
|
||||
active_thread_id: None,
|
||||
active_thread_rx: None,
|
||||
primary_thread_id: None,
|
||||
@@ -3606,6 +3706,7 @@ mod tests {
|
||||
pending_shutdown_exit_thread_id: None,
|
||||
windows_sandbox: WindowsSandboxState::default(),
|
||||
thread_event_channels: HashMap::new(),
|
||||
agent_picker_threads: HashMap::new(),
|
||||
active_thread_id: None,
|
||||
active_thread_rx: None,
|
||||
primary_thread_id: None,
|
||||
|
||||
@@ -54,7 +54,8 @@ pub(crate) enum AppEvent {
|
||||
/// Start a new session.
|
||||
NewSession,
|
||||
|
||||
/// Clear the terminal UI (screen + scrollback) without changing session state.
|
||||
/// Clear the terminal UI (screen + scrollback), start a fresh session, and keep the
|
||||
/// previous chat resumable.
|
||||
ClearUi,
|
||||
|
||||
/// Open the resume picker inside the running TUI session.
|
||||
@@ -320,6 +321,29 @@ pub(crate) enum AppEvent {
|
||||
/// Re-open the permissions presets popup.
|
||||
OpenPermissionsPopup,
|
||||
|
||||
/// Live update for the in-progress voice recording placeholder. Carries
|
||||
/// the placeholder `id` and the text to display (e.g., an ASCII meter).
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
UpdateRecordingMeter {
|
||||
id: String,
|
||||
text: String,
|
||||
},
|
||||
|
||||
/// Voice transcription finished for the given placeholder id.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
TranscriptionComplete {
|
||||
id: String,
|
||||
text: String,
|
||||
},
|
||||
|
||||
/// Voice transcription failed; remove the placeholder identified by `id`.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
TranscriptionFailed {
|
||||
id: String,
|
||||
#[allow(dead_code)]
|
||||
error: String,
|
||||
},
|
||||
|
||||
/// Open the branch picker option from the review popup.
|
||||
OpenReviewBranchPicker(PathBuf),
|
||||
|
||||
|
||||
@@ -109,11 +109,22 @@
|
||||
//! edits and renders a placeholder prompt instead of the editable textarea. This is part of the
|
||||
//! overall state machine, since it affects which transitions are even possible from a given UI
|
||||
//! state.
|
||||
//!
|
||||
//! # Voice Hold-To-Talk Without Key Release
|
||||
//!
|
||||
//! On terminals that do not report `KeyEventKind::Release`, space hold-to-talk uses repeated
|
||||
//! space key events as "still held" evidence:
|
||||
//!
|
||||
//! - For pending holds (non-empty composer), if timeout elapses without any repeated space event,
|
||||
//! we treat the key as a normal typed space.
|
||||
//! - If repeated space events are seen before timeout, we proceed with hold-to-talk.
|
||||
//! - While recording, repeated space events keep the recording alive; if they stop for a short
|
||||
//! window, we stop and transcribe.
|
||||
use crate::bottom_pane::footer::mode_indicator_line;
|
||||
use crate::bottom_pane::selection_popup_common::truncate_line_with_ellipsis_if_overflow;
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::key_hint::has_ctrl_or_alt;
|
||||
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
|
||||
use crate::ui_consts::FOOTER_INDENT_COLS;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
@@ -191,6 +202,7 @@ use crate::bottom_pane::textarea::TextAreaState;
|
||||
use crate::clipboard_paste::normalize_pasted_path;
|
||||
use crate::clipboard_paste::pasted_image_format;
|
||||
use crate::history_cell;
|
||||
use crate::tui::FrameRequester;
|
||||
use crate::ui_consts::LIVE_PREFIX_COLS;
|
||||
use codex_chatgpt::connectors;
|
||||
use codex_chatgpt::connectors::AppInfo;
|
||||
@@ -202,9 +214,17 @@ use std::collections::HashSet;
|
||||
use std::collections::VecDeque;
|
||||
use std::ops::Range;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
use tokio::runtime::Handle;
|
||||
/// If the pasted content exceeds this number of characters, replace it with a
|
||||
/// placeholder in the UI.
|
||||
const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
|
||||
@@ -284,6 +304,35 @@ impl ChatComposerConfig {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct VoiceState {
|
||||
transcription_enabled: bool,
|
||||
// Spacebar hold-to-talk state.
|
||||
space_hold_started_at: Option<Instant>,
|
||||
space_hold_element_id: Option<String>,
|
||||
space_hold_trigger: Option<Arc<AtomicBool>>,
|
||||
key_release_supported: bool,
|
||||
space_hold_repeat_seen: bool,
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
voice: Option<crate::voice::VoiceCapture>,
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
recording_placeholder_id: Option<String>,
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
space_recording_started_at: Option<Instant>,
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
space_recording_last_repeat_at: Option<Instant>,
|
||||
}
|
||||
|
||||
impl VoiceState {
|
||||
fn new(key_release_supported: bool) -> Self {
|
||||
Self {
|
||||
key_release_supported,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ChatComposer {
|
||||
textarea: TextArea,
|
||||
textarea_state: RefCell<TextAreaState>,
|
||||
@@ -299,10 +348,14 @@ pub(crate) struct ChatComposer {
|
||||
pending_pastes: Vec<(String, String)>,
|
||||
large_paste_counters: HashMap<usize, usize>,
|
||||
has_focus: bool,
|
||||
frame_requester: Option<FrameRequester>,
|
||||
/// Invariant: attached images are labeled in vec order as
|
||||
/// `[Image #M+1]..[Image #N]`, where `M` is the number of remote images.
|
||||
attached_images: Vec<AttachedImage>,
|
||||
placeholder_text: String,
|
||||
voice_state: VoiceState,
|
||||
// Spinner control flags keyed by placeholder id; set to true to stop.
|
||||
spinner_stop_flags: HashMap<String, Arc<AtomicBool>>,
|
||||
is_task_running: bool,
|
||||
/// When false, the composer is temporarily read-only (e.g. during sandbox setup).
|
||||
input_enabled: bool,
|
||||
@@ -320,6 +373,9 @@ pub(crate) struct ChatComposer {
|
||||
selected_remote_image_index: Option<usize>,
|
||||
footer_flash: Option<FooterFlash>,
|
||||
context_window_percent: Option<i64>,
|
||||
// Monotonically increasing identifier for textarea elements we insert.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
next_element_id: u64,
|
||||
context_window_used_tokens: Option<i64>,
|
||||
skills: Option<Vec<SkillMetadata>>,
|
||||
connectors_snapshot: Option<ConnectorsSnapshot>,
|
||||
@@ -407,8 +463,11 @@ impl ChatComposer {
|
||||
pending_pastes: Vec::new(),
|
||||
large_paste_counters: HashMap::new(),
|
||||
has_focus: has_input_focus,
|
||||
frame_requester: None,
|
||||
attached_images: Vec::new(),
|
||||
placeholder_text,
|
||||
voice_state: VoiceState::new(enhanced_keys_supported),
|
||||
spinner_stop_flags: HashMap::new(),
|
||||
is_task_running: false,
|
||||
input_enabled: true,
|
||||
input_disabled_placeholder: None,
|
||||
@@ -421,6 +480,8 @@ impl ChatComposer {
|
||||
selected_remote_image_index: None,
|
||||
footer_flash: None,
|
||||
context_window_percent: None,
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
next_element_id: 0,
|
||||
context_window_used_tokens: None,
|
||||
skills: None,
|
||||
connectors_snapshot: None,
|
||||
@@ -442,6 +503,17 @@ impl ChatComposer {
|
||||
this
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn next_id(&mut self) -> String {
|
||||
let id = self.next_element_id;
|
||||
self.next_element_id = self.next_element_id.wrapping_add(1);
|
||||
id.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn set_frame_requester(&mut self, frame_requester: FrameRequester) {
|
||||
self.frame_requester = Some(frame_requester);
|
||||
}
|
||||
|
||||
pub fn set_skill_mentions(&mut self, skills: Option<Vec<SkillMetadata>>) {
|
||||
self.skills = skills;
|
||||
}
|
||||
@@ -505,6 +577,23 @@ impl ChatComposer {
|
||||
pub fn set_personality_command_enabled(&mut self, enabled: bool) {
|
||||
self.personality_command_enabled = enabled;
|
||||
}
|
||||
|
||||
pub fn set_voice_transcription_enabled(&mut self, enabled: bool) {
|
||||
self.voice_state.transcription_enabled = enabled;
|
||||
if !enabled {
|
||||
self.voice_state.space_hold_started_at = None;
|
||||
if let Some(id) = self.voice_state.space_hold_element_id.take() {
|
||||
let _ = self.textarea.replace_element_by_id(&id, " ");
|
||||
}
|
||||
self.voice_state.space_hold_trigger = None;
|
||||
self.voice_state.space_hold_repeat_seen = false;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn voice_transcription_enabled(&self) -> bool {
|
||||
self.voice_state.transcription_enabled && cfg!(not(target_os = "linux"))
|
||||
}
|
||||
/// Centralized feature gating keeps config checks out of call sites.
|
||||
fn popups_enabled(&self) -> bool {
|
||||
self.config.popups_enabled
|
||||
@@ -568,6 +657,20 @@ impl ChatComposer {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
if !self.input_enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Hide the cursor while recording voice input.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
if self.voice_state.voice.is_some() {
|
||||
return None;
|
||||
}
|
||||
let [_, _, textarea_rect, _] = self.layout_areas(area);
|
||||
let state = *self.textarea_state.borrow();
|
||||
self.textarea.cursor_pos_with_state(textarea_rect, state)
|
||||
}
|
||||
/// Returns true if the composer currently contains no user-entered input.
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.textarea.is_empty()
|
||||
@@ -621,6 +724,10 @@ impl ChatComposer {
|
||||
/// In all cases, clears any paste-burst Enter suppression state so a real paste cannot affect
|
||||
/// the next user Enter key, then syncs popup state.
|
||||
pub fn handle_paste(&mut self, pasted: String) -> bool {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
if self.voice_state.voice.is_some() {
|
||||
return false;
|
||||
}
|
||||
let pasted = pasted.replace("\r\n", "\n").replace('\r', "\n");
|
||||
let char_count = pasted.chars().count();
|
||||
if char_count > LARGE_PASTE_CHAR_THRESHOLD {
|
||||
@@ -633,9 +740,8 @@ impl ChatComposer {
|
||||
{
|
||||
self.textarea.insert_str(" ");
|
||||
} else {
|
||||
self.textarea.insert_str(&pasted);
|
||||
self.insert_str(&pasted);
|
||||
}
|
||||
// Explicit paste events should not trigger Enter suppression.
|
||||
self.paste_burst.clear_after_explicit_paste();
|
||||
self.sync_popups();
|
||||
true
|
||||
@@ -866,6 +972,9 @@ impl ChatComposer {
|
||||
local_image_paths: Vec<PathBuf>,
|
||||
mention_bindings: Vec<MentionBinding>,
|
||||
) {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
self.stop_all_transcription_spinners();
|
||||
|
||||
// Clear any existing content, placeholders, and attachments first.
|
||||
self.textarea.set_text_clearing_elements("");
|
||||
self.pending_pastes.clear();
|
||||
@@ -1124,20 +1233,56 @@ impl ChatComposer {
|
||||
|
||||
/// Handle a key event coming from the main UI.
|
||||
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
||||
if matches!(key_event.kind, KeyEventKind::Release) {
|
||||
self.voice_state.key_release_supported = true;
|
||||
}
|
||||
|
||||
// Timer-based conversion is handled in the pre-draw tick.
|
||||
// If recording, stop on Space release when supported. On terminals without key-release
|
||||
// events, Space repeat events are handled as "still held" and stop is driven by timeout
|
||||
// in `process_space_hold_trigger`.
|
||||
if let Some(result) = self.handle_key_event_while_recording(key_event) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if !self.input_enabled {
|
||||
return (InputResult::None, false);
|
||||
}
|
||||
|
||||
// Outside of recording, ignore all key releases globally except for Space,
|
||||
// which is handled explicitly for hold-to-talk behavior below.
|
||||
if matches!(key_event.kind, KeyEventKind::Release)
|
||||
&& !matches!(key_event.code, KeyCode::Char(' '))
|
||||
{
|
||||
return (InputResult::None, false);
|
||||
}
|
||||
|
||||
// If a space hold is pending and another non-space key is pressed, cancel the hold
|
||||
// and convert the element into a plain space.
|
||||
if self.voice_state.space_hold_started_at.is_some()
|
||||
&& !matches!(key_event.code, KeyCode::Char(' '))
|
||||
{
|
||||
self.voice_state.space_hold_started_at = None;
|
||||
if let Some(id) = self.voice_state.space_hold_element_id.take() {
|
||||
let _ = self.textarea.replace_element_by_id(&id, " ");
|
||||
}
|
||||
self.voice_state.space_hold_trigger = None;
|
||||
self.voice_state.space_hold_repeat_seen = false;
|
||||
// fall through to normal handling of this other key
|
||||
}
|
||||
|
||||
if let Some(result) = self.handle_voice_space_key_event(&key_event) {
|
||||
return result;
|
||||
}
|
||||
|
||||
let result = match &mut self.active_popup {
|
||||
ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event),
|
||||
ActivePopup::File(_) => self.handle_key_event_with_file_popup(key_event),
|
||||
ActivePopup::Skill(_) => self.handle_key_event_with_skill_popup(key_event),
|
||||
ActivePopup::None => self.handle_key_event_without_popup(key_event),
|
||||
};
|
||||
|
||||
// Update (or hide/show) popup after processing the key.
|
||||
self.sync_popups();
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
@@ -2535,6 +2680,7 @@ impl ChatComposer {
|
||||
// -------------------------------------------------------------
|
||||
KeyEvent {
|
||||
code: KeyCode::Up | KeyCode::Down,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
@@ -2588,6 +2734,136 @@ impl ChatComposer {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn handle_voice_space_key_event(
|
||||
&mut self,
|
||||
_key_event: &KeyEvent,
|
||||
) -> Option<(InputResult, bool)> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn handle_voice_space_key_event(
|
||||
&mut self,
|
||||
key_event: &KeyEvent,
|
||||
) -> Option<(InputResult, bool)> {
|
||||
if !self.voice_transcription_enabled() || !matches!(key_event.code, KeyCode::Char(' ')) {
|
||||
return None;
|
||||
}
|
||||
match key_event.kind {
|
||||
KeyEventKind::Press => {
|
||||
if self.paste_burst.is_active() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// If textarea is empty, start recording immediately without inserting a space.
|
||||
if self.textarea.text().is_empty() {
|
||||
if self.start_recording_with_placeholder() {
|
||||
return Some((InputResult::None, true));
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
// If a hold is already pending, swallow further press events to
|
||||
// avoid inserting multiple spaces and resetting the timer on key repeat.
|
||||
if self.voice_state.space_hold_started_at.is_some() {
|
||||
if !self.voice_state.key_release_supported {
|
||||
self.voice_state.space_hold_repeat_seen = true;
|
||||
}
|
||||
return Some((InputResult::None, false));
|
||||
}
|
||||
|
||||
// Insert a named element that renders as a space so we can later
|
||||
// remove it on timeout or convert it to a plain space on release.
|
||||
let elem_id = self.next_id();
|
||||
self.textarea.insert_named_element(" ", elem_id.clone());
|
||||
|
||||
// Record pending hold metadata.
|
||||
self.voice_state.space_hold_started_at = Some(Instant::now());
|
||||
self.voice_state.space_hold_element_id = Some(elem_id);
|
||||
self.voice_state.space_hold_repeat_seen = false;
|
||||
|
||||
// Spawn a delayed task to flip an atomic flag; we check it on next key event.
|
||||
let flag = Arc::new(AtomicBool::new(false));
|
||||
let frame = self.frame_requester.clone();
|
||||
Self::schedule_space_hold_timer(flag.clone(), frame);
|
||||
self.voice_state.space_hold_trigger = Some(flag);
|
||||
|
||||
Some((InputResult::None, true))
|
||||
}
|
||||
// If we see a repeat before release, handling occurs in the top-level pending block.
|
||||
KeyEventKind::Repeat => {
|
||||
// Swallow repeats while a hold is pending to avoid extra spaces.
|
||||
if self.voice_state.space_hold_started_at.is_some() {
|
||||
if !self.voice_state.key_release_supported {
|
||||
self.voice_state.space_hold_repeat_seen = true;
|
||||
}
|
||||
return Some((InputResult::None, false));
|
||||
}
|
||||
// Fallback: if no pending hold, treat as normal input.
|
||||
None
|
||||
}
|
||||
// Space release without pending (fallback): treat as normal input.
|
||||
KeyEventKind::Release => {
|
||||
// If a hold is pending, convert the element to a plain space and clear state.
|
||||
self.voice_state.space_hold_started_at = None;
|
||||
if let Some(id) = self.voice_state.space_hold_element_id.take() {
|
||||
let _ = self.textarea.replace_element_by_id(&id, " ");
|
||||
}
|
||||
self.voice_state.space_hold_trigger = None;
|
||||
self.voice_state.space_hold_repeat_seen = false;
|
||||
Some((InputResult::None, true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn handle_key_event_while_recording(
|
||||
&mut self,
|
||||
_key_event: KeyEvent,
|
||||
) -> Option<(InputResult, bool)> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn handle_key_event_while_recording(
|
||||
&mut self,
|
||||
key_event: KeyEvent,
|
||||
) -> Option<(InputResult, bool)> {
|
||||
if self.voice_state.voice.is_some() {
|
||||
let should_stop = if self.voice_state.key_release_supported {
|
||||
match key_event.kind {
|
||||
KeyEventKind::Release => matches!(key_event.code, KeyCode::Char(' ')),
|
||||
KeyEventKind::Press | KeyEventKind::Repeat => {
|
||||
!matches!(key_event.code, KeyCode::Char(' '))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match key_event.kind {
|
||||
KeyEventKind::Release => matches!(key_event.code, KeyCode::Char(' ')),
|
||||
KeyEventKind::Press | KeyEventKind::Repeat => {
|
||||
if matches!(key_event.code, KeyCode::Char(' ')) {
|
||||
self.voice_state.space_recording_last_repeat_at = Some(Instant::now());
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if should_stop {
|
||||
let needs_redraw = self.stop_recording_and_start_transcription();
|
||||
return Some((InputResult::None, needs_redraw));
|
||||
}
|
||||
|
||||
// Swallow non-stopping keys while recording.
|
||||
return Some((InputResult::None, false));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn is_bang_shell_command(&self) -> bool {
|
||||
self.textarea.text().trim_start().starts_with('!')
|
||||
}
|
||||
@@ -2607,8 +2883,6 @@ impl ChatComposer {
|
||||
true
|
||||
}
|
||||
FlushResult::Typed(ch) => {
|
||||
// Mirror insert_str() behavior so popups stay in sync when a
|
||||
// pending fast char flushes as normal typed input.
|
||||
self.textarea.insert_str(ch.to_string().as_str());
|
||||
self.sync_popups();
|
||||
true
|
||||
@@ -2632,6 +2906,12 @@ impl ChatComposer {
|
||||
/// otherwise `clear_window_after_non_char()` can leave buffered text waiting without a
|
||||
/// timestamp to time out against.
|
||||
fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) {
|
||||
// Ignore key releases here to avoid treating them as additional input
|
||||
// (e.g., appending the same character twice via paste-burst logic).
|
||||
if !matches!(input.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
|
||||
return (InputResult::None, false);
|
||||
}
|
||||
|
||||
self.handle_input_basic_with_time(input, Instant::now())
|
||||
}
|
||||
|
||||
@@ -2897,7 +3177,7 @@ impl ChatComposer {
|
||||
.map(|items| if items.is_empty() { 0 } else { 1 })
|
||||
}
|
||||
|
||||
fn sync_popups(&mut self) {
|
||||
pub(crate) fn sync_popups(&mut self) {
|
||||
self.sync_slash_command_elements();
|
||||
if !self.popups_enabled() {
|
||||
self.active_popup = ActivePopup::None;
|
||||
@@ -3311,6 +3591,11 @@ impl ChatComposer {
|
||||
self.has_focus = has_focus;
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub(crate) fn is_recording(&self) -> bool {
|
||||
self.voice_state.voice.is_some()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn set_input_enabled(&mut self, enabled: bool, placeholder: Option<String>) {
|
||||
self.input_enabled = enabled;
|
||||
@@ -3344,6 +3629,32 @@ impl ChatComposer {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn schedule_space_hold_timer(flag: Arc<AtomicBool>, frame: Option<FrameRequester>) {
|
||||
const HOLD_DELAY_MILLIS: u64 = 500;
|
||||
if let Ok(handle) = Handle::try_current() {
|
||||
let flag_clone = flag;
|
||||
let frame_clone = frame;
|
||||
handle.spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(HOLD_DELAY_MILLIS)).await;
|
||||
Self::complete_space_hold_timer(flag_clone, frame_clone);
|
||||
});
|
||||
} else {
|
||||
thread::spawn(move || {
|
||||
thread::sleep(Duration::from_millis(HOLD_DELAY_MILLIS));
|
||||
Self::complete_space_hold_timer(flag, frame);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn complete_space_hold_timer(flag: Arc<AtomicBool>, frame: Option<FrameRequester>) {
|
||||
flag.store(true, Ordering::Relaxed);
|
||||
if let Some(frame) = frame {
|
||||
frame.schedule_frame();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_status_line(&mut self, status_line: Option<Line<'static>>) -> bool {
|
||||
if self.status_line_value == status_line {
|
||||
return false;
|
||||
@@ -3361,6 +3672,280 @@ impl ChatComposer {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
impl ChatComposer {
|
||||
pub(crate) fn process_space_hold_trigger(&mut self) {
|
||||
if self.voice_transcription_enabled()
|
||||
&& let Some(flag) = self.voice_state.space_hold_trigger.as_ref()
|
||||
&& flag.load(Ordering::Relaxed)
|
||||
&& self.voice_state.space_hold_started_at.is_some()
|
||||
&& self.voice_state.voice.is_none()
|
||||
{
|
||||
let _ = self.on_space_hold_timeout();
|
||||
}
|
||||
|
||||
const SPACE_REPEAT_INITIAL_GRACE_MILLIS: u64 = 700;
|
||||
const SPACE_REPEAT_IDLE_TIMEOUT_MILLIS: u64 = 250;
|
||||
if !self.voice_state.key_release_supported && self.voice_state.voice.is_some() {
|
||||
let now = Instant::now();
|
||||
let initial_grace = Duration::from_millis(SPACE_REPEAT_INITIAL_GRACE_MILLIS);
|
||||
let repeat_idle_timeout = Duration::from_millis(SPACE_REPEAT_IDLE_TIMEOUT_MILLIS);
|
||||
if let Some(started_at) = self.voice_state.space_recording_started_at
|
||||
&& now.saturating_duration_since(started_at) >= initial_grace
|
||||
{
|
||||
let should_stop = match self.voice_state.space_recording_last_repeat_at {
|
||||
Some(last_repeat_at) => {
|
||||
now.saturating_duration_since(last_repeat_at) >= repeat_idle_timeout
|
||||
}
|
||||
None => true,
|
||||
};
|
||||
if should_stop {
|
||||
let _ = self.stop_recording_and_start_transcription();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Called when the 500ms space hold timeout elapses.
|
||||
///
|
||||
/// On terminals without key-release reporting, this only transitions into voice capture if we
|
||||
/// observed repeated Space events while pending; otherwise the keypress is treated as a typed
|
||||
/// space.
|
||||
pub(crate) fn on_space_hold_timeout(&mut self) -> bool {
|
||||
if !self.voice_transcription_enabled() {
|
||||
return false;
|
||||
}
|
||||
if self.voice_state.voice.is_some() {
|
||||
return false;
|
||||
}
|
||||
if self.voice_state.space_hold_started_at.is_some() {
|
||||
if !self.voice_state.key_release_supported && !self.voice_state.space_hold_repeat_seen {
|
||||
if let Some(id) = self.voice_state.space_hold_element_id.take() {
|
||||
let _ = self.textarea.replace_element_by_id(&id, " ");
|
||||
}
|
||||
self.voice_state.space_hold_started_at = None;
|
||||
self.voice_state.space_hold_trigger = None;
|
||||
self.voice_state.space_hold_repeat_seen = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Preserve the typed space when transitioning into voice capture, but
|
||||
// avoid duplicating an existing trailing space. In either case,
|
||||
// convert/remove the temporary named element before inserting the
|
||||
// recording/transcribing placeholder.
|
||||
if let Some(id) = self.voice_state.space_hold_element_id.take() {
|
||||
let replacement = if self
|
||||
.textarea
|
||||
.named_element_range(&id)
|
||||
.and_then(|range| self.textarea.text()[..range.start].chars().next_back())
|
||||
.is_some_and(|ch| ch == ' ')
|
||||
{
|
||||
""
|
||||
} else {
|
||||
" "
|
||||
};
|
||||
let _ = self.textarea.replace_element_by_id(&id, replacement);
|
||||
}
|
||||
// Clear pending state before starting capture
|
||||
self.voice_state.space_hold_started_at = None;
|
||||
self.voice_state.space_hold_trigger = None;
|
||||
self.voice_state.space_hold_repeat_seen = false;
|
||||
|
||||
// Start voice capture
|
||||
self.start_recording_with_placeholder()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop recording if active, update the placeholder, and spawn background transcription.
|
||||
/// Returns true if the UI should redraw.
|
||||
fn stop_recording_and_start_transcription(&mut self) -> bool {
|
||||
let Some(vc) = self.voice_state.voice.take() else {
|
||||
return false;
|
||||
};
|
||||
self.voice_state.space_recording_started_at = None;
|
||||
self.voice_state.space_recording_last_repeat_at = None;
|
||||
match vc.stop() {
|
||||
Ok(audio) => {
|
||||
// If the recording is too short, remove the placeholder immediately
|
||||
// and skip the transcribing state entirely.
|
||||
let total_samples = audio.data.len() as f32;
|
||||
let samples_per_second = (audio.sample_rate as f32) * (audio.channels as f32);
|
||||
let duration_seconds = if samples_per_second > 0.0 {
|
||||
total_samples / samples_per_second
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
const MIN_DURATION_SECONDS: f32 = 1.0;
|
||||
if duration_seconds < MIN_DURATION_SECONDS {
|
||||
if let Some(id) = self.voice_state.recording_placeholder_id.take() {
|
||||
let _ = self.textarea.replace_element_by_id(&id, "");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Otherwise, update the placeholder to show a spinner and proceed.
|
||||
let id = match self.voice_state.recording_placeholder_id.take() {
|
||||
Some(id) => id,
|
||||
None => self.next_id(),
|
||||
};
|
||||
|
||||
let placeholder_range = self.textarea.named_element_range(&id);
|
||||
let prompt_source = if let Some(range) = &placeholder_range {
|
||||
self.textarea.text()[..range.start].to_string()
|
||||
} else {
|
||||
self.textarea.text().to_string()
|
||||
};
|
||||
|
||||
// Initialize with first spinner frame immediately.
|
||||
let _ = self.textarea.update_named_element_by_id(&id, "⠋");
|
||||
// Spawn animated braille spinner until transcription finishes (or times out).
|
||||
self.spawn_transcribing_spinner(id.clone());
|
||||
let tx = self.app_event_tx.clone();
|
||||
crate::voice::transcribe_async(id, audio, Some(prompt_source), tx);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("failed to stop voice capture: {e}");
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start voice capture and insert a placeholder element for the live meter.
|
||||
/// Returns true if recording began and UI should redraw; false on failure.
|
||||
fn start_recording_with_placeholder(&mut self) -> bool {
|
||||
match crate::voice::VoiceCapture::start() {
|
||||
Ok(vc) => {
|
||||
self.voice_state.voice = Some(vc);
|
||||
if self.voice_state.key_release_supported {
|
||||
self.voice_state.space_recording_started_at = None;
|
||||
} else {
|
||||
self.voice_state.space_recording_started_at = Some(Instant::now());
|
||||
}
|
||||
self.voice_state.space_recording_last_repeat_at = None;
|
||||
// Insert visible placeholder for the meter (no label)
|
||||
let id = self.next_id();
|
||||
self.textarea.insert_named_element("", id.clone());
|
||||
self.voice_state.recording_placeholder_id = Some(id);
|
||||
// Spawn metering animation
|
||||
if let Some(v) = &self.voice_state.voice {
|
||||
let data = v.data_arc();
|
||||
let stop = v.stopped_flag();
|
||||
let sr = v.sample_rate();
|
||||
let ch = v.channels();
|
||||
let peak = v.last_peak_arc();
|
||||
if let Some(idref) = &self.voice_state.recording_placeholder_id {
|
||||
self.spawn_recording_meter(idref.clone(), sr, ch, data, peak, stop);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
self.voice_state.space_recording_started_at = None;
|
||||
self.voice_state.space_recording_last_repeat_at = None;
|
||||
tracing::error!("failed to start voice capture: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_recording_meter(
|
||||
&self,
|
||||
id: String,
|
||||
_sample_rate: u32,
|
||||
_channels: u16,
|
||||
_data: Arc<Mutex<Vec<i16>>>,
|
||||
last_peak: Arc<std::sync::atomic::AtomicU16>,
|
||||
stop: Arc<std::sync::atomic::AtomicBool>,
|
||||
) {
|
||||
let tx = self.app_event_tx.clone();
|
||||
let task = move || {
|
||||
use std::time::Duration;
|
||||
let mut meter = crate::voice::RecordingMeterState::new();
|
||||
loop {
|
||||
if stop.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let text = meter.next_text(last_peak.load(Ordering::Relaxed));
|
||||
tx.send(crate::app_event::AppEvent::UpdateRecordingMeter {
|
||||
id: id.clone(),
|
||||
text,
|
||||
});
|
||||
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
};
|
||||
|
||||
if let Ok(handle) = Handle::try_current() {
|
||||
handle.spawn_blocking(task);
|
||||
} else {
|
||||
thread::spawn(task);
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_transcribing_spinner(&mut self, id: String) {
|
||||
self.stop_transcription_spinner(&id);
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
self.spinner_stop_flags
|
||||
.insert(id.clone(), Arc::clone(&stop));
|
||||
|
||||
let tx = self.app_event_tx.clone();
|
||||
let task = move || {
|
||||
use std::time::Duration;
|
||||
let frames: Vec<&'static str> = vec!["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
let mut i: usize = 0;
|
||||
// Safety stop after ~60s to avoid a runaway task if events are lost.
|
||||
let max_ticks = 600usize; // 600 * 100ms = 60s
|
||||
for _ in 0..max_ticks {
|
||||
if stop.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let text = frames[i % frames.len()].to_string();
|
||||
tx.send(crate::app_event::AppEvent::UpdateRecordingMeter {
|
||||
id: id.clone(),
|
||||
text,
|
||||
});
|
||||
i = i.wrapping_add(1);
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
};
|
||||
|
||||
if let Ok(handle) = Handle::try_current() {
|
||||
handle.spawn_blocking(task);
|
||||
} else {
|
||||
thread::spawn(task);
|
||||
}
|
||||
}
|
||||
|
||||
fn stop_transcription_spinner(&mut self, id: &str) {
|
||||
if let Some(flag) = self.spinner_stop_flags.remove(id) {
|
||||
flag.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
fn stop_all_transcription_spinners(&mut self) {
|
||||
for (_id, flag) in self.spinner_stop_flags.drain() {
|
||||
flag.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn replace_transcription(&mut self, id: &str, text: &str) {
|
||||
self.stop_transcription_spinner(id);
|
||||
let _ = self.textarea.replace_element_by_id(id, text);
|
||||
}
|
||||
|
||||
pub fn update_transcription_in_place(&mut self, id: &str, text: &str) -> bool {
|
||||
self.textarea.update_named_element_by_id(id, text)
|
||||
}
|
||||
|
||||
pub fn remove_transcription_placeholder(&mut self, id: &str) {
|
||||
self.stop_transcription_spinner(id);
|
||||
let _ = self.textarea.replace_element_by_id(id, "");
|
||||
}
|
||||
}
|
||||
|
||||
fn skill_display_name(skill: &SkillMetadata) -> &str {
|
||||
skill
|
||||
.interface
|
||||
@@ -3787,6 +4372,15 @@ fn prompt_selection_action(
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ChatComposer {
|
||||
fn drop(&mut self) {
|
||||
// Stop any running spinner tasks.
|
||||
for (_id, flag) in self.spinner_stop_flags.drain() {
|
||||
flag.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -5527,11 +6121,19 @@ mod tests {
|
||||
fn type_chars_humanlike(composer: &mut ChatComposer, chars: &[char]) {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use crossterm::event::KeyModifiers;
|
||||
for &ch in chars {
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
|
||||
std::thread::sleep(ChatComposer::recommended_paste_flush_delay());
|
||||
let _ = composer.flush_paste_burst_if_due();
|
||||
if ch == ' ' {
|
||||
let _ = composer.handle_key_event(KeyEvent::new_with_kind(
|
||||
KeyCode::Char(' '),
|
||||
KeyModifiers::NONE,
|
||||
KeyEventKind::Release,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5621,6 +6223,195 @@ mod tests {
|
||||
assert!(found_error, "expected error history cell to be sent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_transcription_disabled_treats_space_as_normal_input() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
true,
|
||||
);
|
||||
composer.set_text_content("x".to_string(), Vec::new(), Vec::new());
|
||||
composer.move_cursor_to_end();
|
||||
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
let _ = composer.handle_key_event(KeyEvent::new_with_kind(
|
||||
KeyCode::Char(' '),
|
||||
KeyModifiers::NONE,
|
||||
KeyEventKind::Release,
|
||||
));
|
||||
|
||||
assert_eq!("x ", composer.textarea.text());
|
||||
assert!(composer.voice_state.space_hold_started_at.is_none());
|
||||
assert!(composer.voice_state.space_hold_element_id.is_none());
|
||||
assert!(composer.voice_state.space_hold_trigger.is_none());
|
||||
assert!(!composer.voice_state.space_hold_repeat_seen);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[test]
|
||||
fn space_hold_timeout_without_release_or_repeat_keeps_typed_space() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_voice_transcription_enabled(true);
|
||||
|
||||
composer.set_text_content("x".to_string(), Vec::new(), Vec::new());
|
||||
composer.move_cursor_to_end();
|
||||
let elem_id = "space-hold".to_string();
|
||||
composer.textarea.insert_named_element(" ", elem_id.clone());
|
||||
composer.voice_state.space_hold_started_at = Some(Instant::now());
|
||||
composer.voice_state.space_hold_element_id = Some(elem_id);
|
||||
composer.voice_state.space_hold_trigger = Some(Arc::new(AtomicBool::new(true)));
|
||||
composer.voice_state.key_release_supported = false;
|
||||
composer.voice_state.space_hold_repeat_seen = false;
|
||||
assert_eq!("x ", composer.textarea.text());
|
||||
|
||||
composer.process_space_hold_trigger();
|
||||
|
||||
assert_eq!("x ", composer.textarea.text());
|
||||
assert!(composer.voice_state.space_hold_started_at.is_none());
|
||||
assert!(!composer.voice_state.space_hold_repeat_seen);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[test]
|
||||
fn space_hold_timeout_with_repeat_uses_hold_path_without_release() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_voice_transcription_enabled(true);
|
||||
|
||||
composer.set_text_content("x".to_string(), Vec::new(), Vec::new());
|
||||
composer.move_cursor_to_end();
|
||||
let elem_id = "space-hold".to_string();
|
||||
composer.textarea.insert_named_element(" ", elem_id.clone());
|
||||
composer.voice_state.space_hold_started_at = Some(Instant::now());
|
||||
composer.voice_state.space_hold_element_id = Some(elem_id);
|
||||
composer.voice_state.space_hold_trigger = Some(Arc::new(AtomicBool::new(true)));
|
||||
composer.voice_state.key_release_supported = false;
|
||||
composer.voice_state.space_hold_repeat_seen = true;
|
||||
|
||||
composer.process_space_hold_trigger();
|
||||
|
||||
assert_eq!("x ", composer.textarea.text());
|
||||
assert!(composer.voice_state.space_hold_started_at.is_none());
|
||||
assert!(!composer.voice_state.space_hold_repeat_seen);
|
||||
if composer.is_recording() {
|
||||
let _ = composer.stop_recording_and_start_transcription();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[test]
|
||||
fn space_hold_timeout_with_repeat_does_not_duplicate_existing_space() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_voice_transcription_enabled(true);
|
||||
|
||||
composer.set_text_content("x ".to_string(), Vec::new(), Vec::new());
|
||||
composer.move_cursor_to_end();
|
||||
let elem_id = "space-hold".to_string();
|
||||
composer.textarea.insert_named_element(" ", elem_id.clone());
|
||||
composer.voice_state.space_hold_started_at = Some(Instant::now());
|
||||
composer.voice_state.space_hold_element_id = Some(elem_id);
|
||||
composer.voice_state.space_hold_trigger = Some(Arc::new(AtomicBool::new(true)));
|
||||
composer.voice_state.key_release_supported = false;
|
||||
composer.voice_state.space_hold_repeat_seen = true;
|
||||
|
||||
composer.process_space_hold_trigger();
|
||||
|
||||
assert_eq!("x ", composer.textarea.text());
|
||||
assert!(composer.voice_state.space_hold_started_at.is_none());
|
||||
assert!(!composer.voice_state.space_hold_repeat_seen);
|
||||
if composer.is_recording() {
|
||||
let _ = composer.stop_recording_and_start_transcription();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[test]
|
||||
fn replace_transcription_stops_spinner_for_placeholder() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
let id = "voice-placeholder".to_string();
|
||||
composer.textarea.insert_named_element("", id.clone());
|
||||
let flag = Arc::new(AtomicBool::new(false));
|
||||
composer
|
||||
.spinner_stop_flags
|
||||
.insert(id.clone(), Arc::clone(&flag));
|
||||
|
||||
composer.replace_transcription(&id, "transcribed text");
|
||||
|
||||
assert!(flag.load(Ordering::Relaxed));
|
||||
assert!(!composer.spinner_stop_flags.contains_key(&id));
|
||||
assert_eq!(composer.textarea.text(), "transcribed text");
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[test]
|
||||
fn set_text_content_stops_all_transcription_spinners() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
let flag_one = Arc::new(AtomicBool::new(false));
|
||||
let flag_two = Arc::new(AtomicBool::new(false));
|
||||
composer
|
||||
.spinner_stop_flags
|
||||
.insert("voice-1".to_string(), Arc::clone(&flag_one));
|
||||
composer
|
||||
.spinner_stop_flags
|
||||
.insert("voice-2".to_string(), Arc::clone(&flag_two));
|
||||
|
||||
composer.set_text_content("draft".to_string(), Vec::new(), Vec::new());
|
||||
|
||||
assert!(flag_one.load(Ordering::Relaxed));
|
||||
assert!(flag_two.load(Ordering::Relaxed));
|
||||
assert!(composer.spinner_stop_flags.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_args_supports_quoted_paths_single_arg() {
|
||||
let args = extract_positional_args_for_prompt_line(
|
||||
|
||||
@@ -220,6 +220,7 @@ impl CommandPopup {
|
||||
};
|
||||
GenericDisplayRow {
|
||||
name,
|
||||
name_prefix_spans: Vec::new(),
|
||||
match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()),
|
||||
display_shortcut: None,
|
||||
description: Some(description),
|
||||
|
||||
@@ -119,6 +119,7 @@ impl WidgetRef for &FileSearchPopup {
|
||||
.iter()
|
||||
.map(|m| GenericDisplayRow {
|
||||
name: m.path.to_string_lossy().to_string(),
|
||||
name_prefix_spans: Vec::new(),
|
||||
match_indices: m
|
||||
.indices
|
||||
.as_ref()
|
||||
|
||||
@@ -990,7 +990,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::bottom_pane::selection_popup_common::truncate_line_with_ellipsis_if_overflow;
|
||||
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
|
||||
use crate::test_backend::VT100Backend;
|
||||
use insta::assert_snapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
@@ -112,6 +112,7 @@ pub(crate) type OnCancelCallback = Option<Box<dyn Fn(&AppEventSender) + Send + S
|
||||
#[derive(Default)]
|
||||
pub(crate) struct SelectionItem {
|
||||
pub name: String,
|
||||
pub name_prefix_spans: Vec<Span<'static>>,
|
||||
pub display_shortcut: Option<KeyBinding>,
|
||||
pub description: Option<String>,
|
||||
pub selected_description: Option<String>,
|
||||
@@ -372,7 +373,9 @@ impl ListSelectionView {
|
||||
format!("{prefix} {n}. ")
|
||||
};
|
||||
let wrap_prefix_width = UnicodeWidthStr::width(wrap_prefix.as_str());
|
||||
let display_name = format!("{wrap_prefix}{name_with_marker}");
|
||||
let mut name_prefix_spans = Vec::new();
|
||||
name_prefix_spans.push(wrap_prefix.into());
|
||||
name_prefix_spans.extend(item.name_prefix_spans.clone());
|
||||
let description = is_selected
|
||||
.then(|| item.selected_description.clone())
|
||||
.flatten()
|
||||
@@ -380,7 +383,8 @@ impl ListSelectionView {
|
||||
let wrap_indent = description.is_none().then_some(wrap_prefix_width);
|
||||
let is_disabled = item.is_disabled || item.disabled_reason.is_some();
|
||||
GenericDisplayRow {
|
||||
name: display_name,
|
||||
name: name_with_marker,
|
||||
name_prefix_spans,
|
||||
display_shortcut: item.display_shortcut,
|
||||
match_indices: None,
|
||||
description,
|
||||
|
||||
@@ -33,6 +33,7 @@ use codex_protocol::request_user_input::RequestUserInputEvent;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::text::Line;
|
||||
@@ -131,6 +132,7 @@ pub(crate) use chat_composer::ChatComposerConfig;
|
||||
pub(crate) use chat_composer::InputResult;
|
||||
use codex_protocol::custom_prompts::CustomPrompt;
|
||||
|
||||
use crate::status_indicator_widget::StatusDetailsCapitalization;
|
||||
use crate::status_indicator_widget::StatusIndicatorWidget;
|
||||
pub(crate) use experimental_features_view::ExperimentalFeatureItem;
|
||||
pub(crate) use experimental_features_view::ExperimentalFeaturesView;
|
||||
@@ -203,8 +205,8 @@ impl BottomPane {
|
||||
placeholder_text,
|
||||
disable_paste_burst,
|
||||
);
|
||||
composer.set_frame_requester(frame_requester.clone());
|
||||
composer.set_skill_mentions(skills);
|
||||
|
||||
Self {
|
||||
composer,
|
||||
view_stack: Vec::new(),
|
||||
@@ -290,6 +292,11 @@ impl BottomPane {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub fn set_voice_transcription_enabled(&mut self, enabled: bool) {
|
||||
self.composer.set_voice_transcription_enabled(enabled);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Update the key hint shown next to queued messages so it matches the
|
||||
/// binding that `ChatWidget` actually listens for.
|
||||
pub(crate) fn set_queued_message_edit_binding(&mut self, binding: KeyBinding) {
|
||||
@@ -326,8 +333,23 @@ impl BottomPane {
|
||||
|
||||
/// Forward a key event to the active view or the composer.
|
||||
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult {
|
||||
// Do not globally intercept space; only composer handles hold-to-talk.
|
||||
// While recording, route all keys to the composer so it can stop on release or next key.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
if self.composer.is_recording() {
|
||||
let (_ir, needs_redraw) = self.composer.handle_key_event(key_event);
|
||||
if needs_redraw {
|
||||
self.request_redraw();
|
||||
}
|
||||
return InputResult::None;
|
||||
}
|
||||
|
||||
// If a modal/view is active, handle it here; otherwise forward to composer.
|
||||
if !self.view_stack.is_empty() {
|
||||
if key_event.kind == KeyEventKind::Release {
|
||||
return InputResult::None;
|
||||
}
|
||||
|
||||
// We need three pieces of information after routing the key:
|
||||
// whether Esc completed the view, whether the view finished for any
|
||||
// reason, and whether a paste-burst timer should be scheduled.
|
||||
@@ -431,6 +453,7 @@ impl BottomPane {
|
||||
}
|
||||
} else {
|
||||
let needs_redraw = self.composer.handle_paste(pasted);
|
||||
self.composer.sync_popups();
|
||||
if needs_redraw {
|
||||
self.request_redraw();
|
||||
}
|
||||
@@ -439,9 +462,18 @@ impl BottomPane {
|
||||
|
||||
pub(crate) fn insert_str(&mut self, text: &str) {
|
||||
self.composer.insert_str(text);
|
||||
self.composer.sync_popups();
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
// Space hold timeout is handled inside ChatComposer via an internal timer.
|
||||
pub(crate) fn pre_draw_tick(&mut self) {
|
||||
// Allow composer to process any time-based transitions before drawing
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
self.composer.process_space_hold_trigger();
|
||||
self.composer.sync_popups();
|
||||
}
|
||||
|
||||
/// Replace the composer text with `text`.
|
||||
///
|
||||
/// This is intended for fresh input where mention linkage does not need to
|
||||
@@ -549,10 +581,16 @@ impl BottomPane {
|
||||
/// Update the status indicator header (defaults to "Working") and details below it.
|
||||
///
|
||||
/// Passing `None` clears any existing details. No-ops if the status indicator is not active.
|
||||
pub(crate) fn update_status(&mut self, header: String, details: Option<String>) {
|
||||
pub(crate) fn update_status(
|
||||
&mut self,
|
||||
header: String,
|
||||
details: Option<String>,
|
||||
details_capitalization: StatusDetailsCapitalization,
|
||||
details_max_lines: usize,
|
||||
) {
|
||||
if let Some(status) = self.status.as_mut() {
|
||||
status.update_header(header);
|
||||
status.update_details(details);
|
||||
status.update_details(details, details_capitalization, details_max_lines.max(1));
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
@@ -888,6 +926,7 @@ impl BottomPane {
|
||||
.on_history_entry_response(log_id, offset, entry);
|
||||
|
||||
if updated {
|
||||
self.composer.sync_popups();
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
@@ -966,6 +1005,30 @@ impl BottomPane {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
impl BottomPane {
|
||||
pub(crate) fn replace_transcription(&mut self, id: &str, text: &str) {
|
||||
self.composer.replace_transcription(id, text);
|
||||
self.composer.sync_popups();
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn update_transcription_in_place(&mut self, id: &str, text: &str) -> bool {
|
||||
let updated = self.composer.update_transcription_in_place(id, text);
|
||||
if updated {
|
||||
self.composer.sync_popups();
|
||||
self.request_redraw();
|
||||
}
|
||||
updated
|
||||
}
|
||||
|
||||
pub(crate) fn remove_transcription_placeholder(&mut self, id: &str) {
|
||||
self.composer.remove_transcription_placeholder(id);
|
||||
self.composer.sync_popups();
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for BottomPane {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
self.as_renderable().render(area, buf);
|
||||
@@ -982,8 +1045,11 @@ impl Renderable for BottomPane {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES;
|
||||
use crate::status_indicator_widget::StatusDetailsCapitalization;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use insta::assert_snapshot;
|
||||
use ratatui::buffer::Buffer;
|
||||
@@ -1275,6 +1341,8 @@ mod tests {
|
||||
pane.update_status(
|
||||
"Working".to_string(),
|
||||
Some("First detail line\nSecond detail line".to_string()),
|
||||
StatusDetailsCapitalization::CapitalizeFirst,
|
||||
STATUS_DETAILS_DEFAULT_MAX_LINES,
|
||||
);
|
||||
pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]);
|
||||
|
||||
@@ -1560,4 +1628,58 @@ mod tests {
|
||||
assert_eq!(on_ctrl_c_calls.get(), 0);
|
||||
assert_eq!(handle_calls.get(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_events_are_ignored_for_active_view() {
|
||||
#[derive(Default)]
|
||||
struct CountingView {
|
||||
handle_calls: Rc<Cell<usize>>,
|
||||
}
|
||||
|
||||
impl Renderable for CountingView {
|
||||
fn render(&self, _area: Rect, _buf: &mut Buffer) {}
|
||||
|
||||
fn desired_height(&self, _width: u16) -> u16 {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
impl BottomPaneView for CountingView {
|
||||
fn handle_key_event(&mut self, _key_event: KeyEvent) {
|
||||
self.handle_calls
|
||||
.set(self.handle_calls.get().saturating_add(1));
|
||||
}
|
||||
}
|
||||
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
animations_enabled: true,
|
||||
skills: Some(Vec::new()),
|
||||
});
|
||||
|
||||
let handle_calls = Rc::new(Cell::new(0));
|
||||
pane.push_view(Box::new(CountingView {
|
||||
handle_calls: Rc::clone(&handle_calls),
|
||||
}));
|
||||
|
||||
pane.handle_key_event(KeyEvent::new_with_kind(
|
||||
KeyCode::Down,
|
||||
KeyModifiers::NONE,
|
||||
KeyEventKind::Press,
|
||||
));
|
||||
pane.handle_key_event(KeyEvent::new_with_kind(
|
||||
KeyCode::Down,
|
||||
KeyModifiers::NONE,
|
||||
KeyEventKind::Release,
|
||||
));
|
||||
|
||||
assert_eq!(handle_calls.get(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,8 +46,8 @@ use crate::bottom_pane::bottom_pane_view::BottomPaneView;
|
||||
use crate::bottom_pane::popup_consts::MAX_POPUP_ROWS;
|
||||
use crate::bottom_pane::scroll_state::ScrollState;
|
||||
use crate::bottom_pane::selection_popup_common::render_rows_single_line;
|
||||
use crate::bottom_pane::selection_popup_common::truncate_line_with_ellipsis_if_overflow;
|
||||
use crate::key_hint;
|
||||
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
|
||||
use crate::render::Insets;
|
||||
use crate::render::RectExt;
|
||||
use crate::render::renderable::ColumnRenderable;
|
||||
|
||||
@@ -14,6 +14,7 @@ use unicode_width::UnicodeWidthChar;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
|
||||
use crate::render::Insets;
|
||||
use crate::render::RectExt as _;
|
||||
use crate::style::user_message_style;
|
||||
@@ -28,6 +29,7 @@ use super::scroll_state::ScrollState;
|
||||
#[derive(Default)]
|
||||
pub(crate) struct GenericDisplayRow {
|
||||
pub name: String,
|
||||
pub name_prefix_spans: Vec<Span<'static>>,
|
||||
pub display_shortcut: Option<KeyBinding>,
|
||||
pub match_indices: Option<Vec<usize>>, // indices to bold (char positions)
|
||||
pub description: Option<String>, // optional grey text after the name
|
||||
@@ -104,12 +106,6 @@ pub(crate) fn wrap_styled_line<'a>(line: &'a Line<'a>, width: u16) -> Vec<Line<'
|
||||
word_wrap_line(line, opts)
|
||||
}
|
||||
|
||||
fn line_width(line: &Line<'_>) -> usize {
|
||||
line.iter()
|
||||
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn line_to_owned(line: Line<'_>) -> Line<'static> {
|
||||
Line {
|
||||
style: line.style,
|
||||
@@ -125,91 +121,6 @@ fn line_to_owned(line: Line<'_>) -> Line<'static> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> Line<'static> {
|
||||
if max_width == 0 {
|
||||
return Line::from(Vec::<Span<'static>>::new());
|
||||
}
|
||||
|
||||
let Line {
|
||||
style,
|
||||
alignment,
|
||||
spans,
|
||||
} = line;
|
||||
let mut used = 0usize;
|
||||
let mut spans_out: Vec<Span<'static>> = Vec::new();
|
||||
|
||||
for span in spans {
|
||||
let text = span.content.into_owned();
|
||||
let style = span.style;
|
||||
let span_width = UnicodeWidthStr::width(text.as_str());
|
||||
|
||||
if span_width == 0 {
|
||||
spans_out.push(Span::styled(text, style));
|
||||
continue;
|
||||
}
|
||||
|
||||
if used >= max_width {
|
||||
break;
|
||||
}
|
||||
|
||||
if used + span_width <= max_width {
|
||||
used += span_width;
|
||||
spans_out.push(Span::styled(text, style));
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut truncated = String::new();
|
||||
for ch in text.chars() {
|
||||
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
|
||||
if used + ch_width > max_width {
|
||||
break;
|
||||
}
|
||||
truncated.push(ch);
|
||||
used += ch_width;
|
||||
}
|
||||
|
||||
if !truncated.is_empty() {
|
||||
spans_out.push(Span::styled(truncated, style));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
Line {
|
||||
style,
|
||||
alignment,
|
||||
spans: spans_out,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn truncate_line_with_ellipsis_if_overflow(
|
||||
line: Line<'static>,
|
||||
max_width: usize,
|
||||
) -> Line<'static> {
|
||||
if max_width == 0 {
|
||||
return Line::from(Vec::<Span<'static>>::new());
|
||||
}
|
||||
|
||||
let width = line_width(&line);
|
||||
if width <= max_width {
|
||||
return line;
|
||||
}
|
||||
|
||||
let truncated = truncate_line_to_width(line, max_width.saturating_sub(1));
|
||||
let Line {
|
||||
style,
|
||||
alignment,
|
||||
mut spans,
|
||||
} = truncated;
|
||||
let ellipsis_style = spans.last().map(|span| span.style).unwrap_or_default();
|
||||
spans.push(Span::styled("…", ellipsis_style));
|
||||
Line {
|
||||
style,
|
||||
alignment,
|
||||
spans,
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_desc_col(
|
||||
rows_all: &[GenericDisplayRow],
|
||||
start_idx: usize,
|
||||
@@ -242,7 +153,8 @@ fn compute_desc_col(
|
||||
.skip(start_idx)
|
||||
.take(visible_items)
|
||||
.map(|(_, row)| {
|
||||
let mut spans: Vec<Span> = vec![row.name.clone().into()];
|
||||
let mut spans = row.name_prefix_spans.clone();
|
||||
spans.push(row.name.clone().into());
|
||||
if row.disabled_reason.is_some() {
|
||||
spans.push(" (disabled)".dim());
|
||||
}
|
||||
@@ -253,7 +165,8 @@ fn compute_desc_col(
|
||||
ColumnWidthMode::AutoAllRows => rows_all
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let mut spans: Vec<Span> = vec![row.name.clone().into()];
|
||||
let mut spans = row.name_prefix_spans.clone();
|
||||
spans.push(row.name.clone().into());
|
||||
if row.disabled_reason.is_some() {
|
||||
spans.push(" (disabled)".dim());
|
||||
}
|
||||
@@ -291,6 +204,7 @@ fn should_wrap_name_in_column(row: &GenericDisplayRow) -> bool {
|
||||
&& row.match_indices.is_none()
|
||||
&& row.display_shortcut.is_none()
|
||||
&& row.category_tag.is_none()
|
||||
&& row.name_prefix_spans.is_empty()
|
||||
}
|
||||
|
||||
fn wrap_two_column_row(row: &GenericDisplayRow, desc_col: usize, width: u16) -> Vec<Line<'static>> {
|
||||
@@ -508,9 +422,10 @@ fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> {
|
||||
|
||||
// Enforce single-line name: allow at most desc_col - 2 cells for name,
|
||||
// reserving two spaces before the description column.
|
||||
let name_prefix_width = Line::from(row.name_prefix_spans.clone()).width();
|
||||
let name_limit = combined_description
|
||||
.as_ref()
|
||||
.map(|_| desc_col.saturating_sub(2))
|
||||
.map(|_| desc_col.saturating_sub(2).saturating_sub(name_prefix_width))
|
||||
.unwrap_or(usize::MAX);
|
||||
|
||||
let mut name_spans: Vec<Span> = Vec::with_capacity(row.name.len());
|
||||
@@ -558,8 +473,9 @@ fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> {
|
||||
name_spans.push(" (disabled)".dim());
|
||||
}
|
||||
|
||||
let this_name_width = Line::from(name_spans.clone()).width();
|
||||
let mut full_spans: Vec<Span> = name_spans;
|
||||
let this_name_width = name_prefix_width + Line::from(name_spans.clone()).width();
|
||||
let mut full_spans: Vec<Span> = row.name_prefix_spans.clone();
|
||||
full_spans.extend(name_spans);
|
||||
if let Some(display_shortcut) = row.display_shortcut {
|
||||
full_spans.push(" (".into());
|
||||
full_spans.push(display_shortcut.into());
|
||||
|
||||
@@ -101,6 +101,7 @@ impl SkillPopup {
|
||||
let description = mention.description.clone().unwrap_or_default();
|
||||
GenericDisplayRow {
|
||||
name,
|
||||
name_prefix_spans: Vec::new(),
|
||||
match_indices: indices,
|
||||
display_shortcut: None,
|
||||
description: Some(description).filter(|desc| !desc.is_empty()),
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
source: tui/src/bottom_pane/mod.rs
|
||||
expression: "render_snapshot(&pane, area)"
|
||||
---
|
||||
• Working (0s • esc to interru
|
||||
• Working (0s • esc to interr…
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
@@ -3,6 +3,7 @@ use codex_protocol::user_input::ByteRange;
|
||||
use codex_protocol::user_input::TextElement as UserTextElement;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
@@ -27,6 +28,7 @@ fn is_word_separator(ch: char) -> bool {
|
||||
struct TextElement {
|
||||
id: u64,
|
||||
range: Range<usize>,
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -101,6 +103,7 @@ impl TextArea {
|
||||
self.elements.push(TextElement {
|
||||
id,
|
||||
range: start..end,
|
||||
name: None,
|
||||
});
|
||||
}
|
||||
self.elements.sort_by_key(|e| e.range.start);
|
||||
@@ -256,6 +259,11 @@ impl TextArea {
|
||||
}
|
||||
|
||||
pub fn input(&mut self, event: KeyEvent) {
|
||||
// Only process key presses or repeats; ignore releases to avoid inserting
|
||||
// characters on key-up events when modifiers are no longer reported.
|
||||
if !matches!(event.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
|
||||
return;
|
||||
}
|
||||
match event {
|
||||
// Some terminals (or configurations) send Control key chords as
|
||||
// C0 control characters without reporting the CONTROL modifier.
|
||||
@@ -886,6 +894,73 @@ impl TextArea {
|
||||
id
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn insert_named_element(&mut self, text: &str, id: String) {
|
||||
let start = self.clamp_pos_for_insertion(self.cursor_pos);
|
||||
self.insert_str_at(start, text);
|
||||
let end = start + text.len();
|
||||
self.add_element_with_id(start..end, Some(id));
|
||||
// Place cursor at end of inserted element
|
||||
self.set_cursor(end);
|
||||
}
|
||||
|
||||
pub fn replace_element_by_id(&mut self, id: &str, text: &str) -> bool {
|
||||
if let Some(idx) = self
|
||||
.elements
|
||||
.iter()
|
||||
.position(|e| e.name.as_deref() == Some(id))
|
||||
{
|
||||
let range = self.elements[idx].range.clone();
|
||||
self.replace_range_raw(range, text);
|
||||
self.elements.retain(|e| e.name.as_deref() != Some(id));
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the element's text in place, preserving its id so callers can
|
||||
/// update it again later (e.g. recording -> transcribing -> final).
|
||||
#[allow(dead_code)]
|
||||
pub fn update_named_element_by_id(&mut self, id: &str, text: &str) -> bool {
|
||||
if let Some(elem_idx) = self
|
||||
.elements
|
||||
.iter()
|
||||
.position(|e| e.name.as_deref() == Some(id))
|
||||
{
|
||||
let old_range = self.elements[elem_idx].range.clone();
|
||||
let start = old_range.start;
|
||||
self.replace_range_raw(old_range, text);
|
||||
// After replace_range_raw, the old element entry was removed if fully overlapped.
|
||||
// Re-add an updated element with the same id and new range.
|
||||
let new_end = start + text.len();
|
||||
self.add_element_with_id(start..new_end, Some(id.to_string()));
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn named_element_range(&self, id: &str) -> Option<std::ops::Range<usize>> {
|
||||
self.elements
|
||||
.iter()
|
||||
.find(|e| e.name.as_deref() == Some(id))
|
||||
.map(|e| e.range.clone())
|
||||
}
|
||||
|
||||
fn add_element_with_id(&mut self, range: Range<usize>, name: Option<String>) -> u64 {
|
||||
let id = self.next_element_id();
|
||||
let elem = TextElement { id, range, name };
|
||||
self.elements.push(elem);
|
||||
self.elements.sort_by_key(|e| e.range.start);
|
||||
id
|
||||
}
|
||||
|
||||
fn add_element(&mut self, range: Range<usize>) -> u64 {
|
||||
self.add_element_with_id(range, None)
|
||||
}
|
||||
|
||||
/// Mark an existing text range as an atomic element without changing the text.
|
||||
///
|
||||
/// This is used to convert already-typed tokens (like `/plan`) into elements
|
||||
@@ -910,12 +985,7 @@ impl TextArea {
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let id = self.next_element_id();
|
||||
self.elements.push(TextElement {
|
||||
id,
|
||||
range: start..end,
|
||||
});
|
||||
self.elements.sort_by_key(|e| e.range.start);
|
||||
let id = self.add_element(start..end);
|
||||
Some(id)
|
||||
}
|
||||
|
||||
@@ -931,20 +1001,11 @@ impl TextArea {
|
||||
len_before != self.elements.len()
|
||||
}
|
||||
|
||||
fn add_element(&mut self, range: Range<usize>) -> u64 {
|
||||
let id = self.next_element_id();
|
||||
let elem = TextElement { id, range };
|
||||
self.elements.push(elem);
|
||||
self.elements.sort_by_key(|e| e.range.start);
|
||||
id
|
||||
}
|
||||
|
||||
fn next_element_id(&mut self) -> u64 {
|
||||
let id = self.next_element_id;
|
||||
self.next_element_id = self.next_element_id.saturating_add(1);
|
||||
id
|
||||
}
|
||||
|
||||
fn find_element_containing(&self, pos: usize) -> Option<usize> {
|
||||
self.elements
|
||||
.iter()
|
||||
|
||||
+106
-41
@@ -49,6 +49,7 @@ use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_backend_client::Client as BackendClient;
|
||||
use codex_chatgpt::connectors;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::Constrained;
|
||||
use codex_core::config::ConstraintResult;
|
||||
use codex_core::config::types::Notifications;
|
||||
use codex_core::config::types::WindowsSandboxModeToml;
|
||||
@@ -242,6 +243,8 @@ use crate::render::renderable::RenderableExt;
|
||||
use crate::render::renderable::RenderableItem;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::status::RateLimitSnapshotDisplay;
|
||||
use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES;
|
||||
use crate::status_indicator_widget::StatusDetailsCapitalization;
|
||||
use crate::text_formatting::truncate_text;
|
||||
use crate::tui::FrameRequester;
|
||||
mod interrupts;
|
||||
@@ -910,15 +913,27 @@ impl ChatWidget {
|
||||
/// Update the status indicator header and details.
|
||||
///
|
||||
/// Passing `None` clears any existing details.
|
||||
fn set_status(&mut self, header: String, details: Option<String>) {
|
||||
fn set_status(
|
||||
&mut self,
|
||||
header: String,
|
||||
details: Option<String>,
|
||||
details_capitalization: StatusDetailsCapitalization,
|
||||
details_max_lines: usize,
|
||||
) {
|
||||
self.current_status_header = header.clone();
|
||||
self.bottom_pane.update_status(header, details);
|
||||
self.bottom_pane
|
||||
.update_status(header, details, details_capitalization, details_max_lines);
|
||||
}
|
||||
|
||||
/// Convenience wrapper around [`Self::set_status`];
|
||||
/// updates the status indicator header and clears any existing details.
|
||||
fn set_status_header(&mut self, header: String) {
|
||||
self.set_status(header, None);
|
||||
self.set_status(
|
||||
header,
|
||||
None,
|
||||
StatusDetailsCapitalization::CapitalizeFirst,
|
||||
STATUS_DETAILS_DEFAULT_MAX_LINES,
|
||||
);
|
||||
}
|
||||
|
||||
/// Sets the currently rendered footer status-line value.
|
||||
@@ -1076,6 +1091,27 @@ impl ChatWidget {
|
||||
self.forked_from = event.forked_from_id;
|
||||
self.current_rollout_path = event.rollout_path.clone();
|
||||
self.current_cwd = Some(event.cwd.clone());
|
||||
self.config.cwd = event.cwd.clone();
|
||||
if let Err(err) = self
|
||||
.config
|
||||
.permissions
|
||||
.approval_policy
|
||||
.set(event.approval_policy)
|
||||
{
|
||||
tracing::warn!(%err, "failed to sync approval_policy from SessionConfigured");
|
||||
self.config.permissions.approval_policy =
|
||||
Constrained::allow_only(event.approval_policy);
|
||||
}
|
||||
if let Err(err) = self
|
||||
.config
|
||||
.permissions
|
||||
.sandbox_policy
|
||||
.set(event.sandbox_policy.clone())
|
||||
{
|
||||
tracing::warn!(%err, "failed to sync sandbox_policy from SessionConfigured");
|
||||
self.config.permissions.sandbox_policy =
|
||||
Constrained::allow_only(event.sandbox_policy.clone());
|
||||
}
|
||||
let initial_messages = event.initial_messages.clone();
|
||||
let forked_from_id = event.forked_from_id;
|
||||
let model_for_header = event.model.clone();
|
||||
@@ -1933,15 +1969,16 @@ impl ChatWidget {
|
||||
.map(|process| process.command_display.clone());
|
||||
if ev.stdin.is_empty() {
|
||||
// Empty stdin means we are polling for background output.
|
||||
// Surface this in the status header (single "waiting" surface) instead of the transcript.
|
||||
// Surface this in the status indicator (single "waiting" surface) instead of
|
||||
// the transcript. Keep the header short so the interrupt hint remains visible.
|
||||
self.bottom_pane.ensure_status_indicator();
|
||||
self.bottom_pane.set_interrupt_hint_visible(true);
|
||||
let header = if let Some(command) = &command_display {
|
||||
format!("Waiting for background terminal · {command}")
|
||||
} else {
|
||||
"Waiting for background terminal".to_string()
|
||||
};
|
||||
self.set_status_header(header);
|
||||
self.set_status(
|
||||
"Waiting for background terminal".to_string(),
|
||||
command_display.clone(),
|
||||
StatusDetailsCapitalization::Preserve,
|
||||
1,
|
||||
);
|
||||
match &mut self.unified_exec_wait_streak {
|
||||
Some(wait) if wait.process_id == ev.process_id => {
|
||||
wait.update_command_display(command_display);
|
||||
@@ -2214,7 +2251,16 @@ impl ChatWidget {
|
||||
self.retry_status_header = Some(self.current_status_header.clone());
|
||||
}
|
||||
self.bottom_pane.ensure_status_indicator();
|
||||
self.set_status(message, additional_details);
|
||||
self.set_status(
|
||||
message,
|
||||
additional_details,
|
||||
StatusDetailsCapitalization::CapitalizeFirst,
|
||||
STATUS_DETAILS_DEFAULT_MAX_LINES,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn pre_draw_tick(&mut self) {
|
||||
self.bottom_pane.pre_draw_tick();
|
||||
}
|
||||
|
||||
/// Handle completion of an `AgentMessage` turn item.
|
||||
@@ -2806,12 +2852,13 @@ impl ChatWidget {
|
||||
widget
|
||||
.bottom_pane
|
||||
.set_steer_enabled(widget.config.features.enabled(Feature::Steer));
|
||||
widget.bottom_pane.set_voice_transcription_enabled(
|
||||
widget.config.features.enabled(Feature::VoiceTranscription),
|
||||
);
|
||||
widget
|
||||
.bottom_pane
|
||||
.set_status_line_enabled(!widget.configured_status_line_items().is_empty());
|
||||
widget.bottom_pane.set_collaboration_modes_enabled(
|
||||
widget.config.features.enabled(Feature::CollaborationModes),
|
||||
);
|
||||
widget.bottom_pane.set_collaboration_modes_enabled(true);
|
||||
widget.sync_personality_command_enabled();
|
||||
widget
|
||||
.bottom_pane
|
||||
@@ -2976,12 +3023,13 @@ impl ChatWidget {
|
||||
widget
|
||||
.bottom_pane
|
||||
.set_steer_enabled(widget.config.features.enabled(Feature::Steer));
|
||||
widget.bottom_pane.set_voice_transcription_enabled(
|
||||
widget.config.features.enabled(Feature::VoiceTranscription),
|
||||
);
|
||||
widget
|
||||
.bottom_pane
|
||||
.set_status_line_enabled(!widget.configured_status_line_items().is_empty());
|
||||
widget.bottom_pane.set_collaboration_modes_enabled(
|
||||
widget.config.features.enabled(Feature::CollaborationModes),
|
||||
);
|
||||
widget.bottom_pane.set_collaboration_modes_enabled(true);
|
||||
widget.sync_personality_command_enabled();
|
||||
widget
|
||||
.bottom_pane
|
||||
@@ -3135,12 +3183,13 @@ impl ChatWidget {
|
||||
widget
|
||||
.bottom_pane
|
||||
.set_steer_enabled(widget.config.features.enabled(Feature::Steer));
|
||||
widget.bottom_pane.set_voice_transcription_enabled(
|
||||
widget.config.features.enabled(Feature::VoiceTranscription),
|
||||
);
|
||||
widget
|
||||
.bottom_pane
|
||||
.set_status_line_enabled(!widget.configured_status_line_items().is_empty());
|
||||
widget.bottom_pane.set_collaboration_modes_enabled(
|
||||
widget.config.features.enabled(Feature::CollaborationModes),
|
||||
);
|
||||
widget.bottom_pane.set_collaboration_modes_enabled(true);
|
||||
widget.sync_personality_command_enabled();
|
||||
widget
|
||||
.bottom_pane
|
||||
@@ -3258,7 +3307,14 @@ impl ChatWidget {
|
||||
.bottom_pane
|
||||
.take_recent_submission_mention_bindings(),
|
||||
};
|
||||
if self.is_session_configured() && !self.is_plan_streaming_in_tui() {
|
||||
// Steer submissions during active final-answer streaming can race with turn
|
||||
// completion and strand the UI in a running state. Queue those inputs instead
|
||||
// of injecting immediately; `on_task_complete()` drains this FIFO via
|
||||
// `maybe_send_next_queued_input()`, so no typed prompt is dropped.
|
||||
let should_submit_now = self.is_session_configured()
|
||||
&& !self.is_plan_streaming_in_tui()
|
||||
&& self.stream_controller.is_none();
|
||||
if should_submit_now {
|
||||
// Submitted is only emitted when steer is enabled.
|
||||
// Reset any reasoning header only when we are actually submitting a turn.
|
||||
self.reasoning_buffer.clear();
|
||||
@@ -6270,6 +6326,8 @@ impl ChatWidget {
|
||||
self.set_status(
|
||||
"Setting up sandbox...".to_string(),
|
||||
Some("Hang tight, this may take a few minutes".to_string()),
|
||||
StatusDetailsCapitalization::CapitalizeFirst,
|
||||
STATUS_DETAILS_DEFAULT_MAX_LINES,
|
||||
);
|
||||
self.request_redraw();
|
||||
}
|
||||
@@ -6325,21 +6383,8 @@ impl ChatWidget {
|
||||
if feature == Feature::Steer {
|
||||
self.bottom_pane.set_steer_enabled(enabled);
|
||||
}
|
||||
if feature == Feature::CollaborationModes {
|
||||
self.bottom_pane.set_collaboration_modes_enabled(enabled);
|
||||
let settings = self.current_collaboration_mode.settings.clone();
|
||||
self.current_collaboration_mode = CollaborationMode {
|
||||
mode: ModeKind::Default,
|
||||
settings,
|
||||
};
|
||||
self.active_collaboration_mask = if enabled {
|
||||
collaboration_modes::default_mask(self.models_manager.as_ref())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.update_collaboration_mode_indicator();
|
||||
self.refresh_model_display();
|
||||
self.request_redraw();
|
||||
if feature == Feature::VoiceTranscription {
|
||||
self.bottom_pane.set_voice_transcription_enabled(enabled);
|
||||
}
|
||||
if feature == Feature::Personality {
|
||||
self.sync_personality_command_enabled();
|
||||
@@ -6519,17 +6564,14 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
fn collaboration_modes_enabled(&self) -> bool {
|
||||
self.config.features.enabled(Feature::CollaborationModes)
|
||||
true
|
||||
}
|
||||
|
||||
fn initial_collaboration_mask(
|
||||
config: &Config,
|
||||
_config: &Config,
|
||||
models_manager: &ModelsManager,
|
||||
model_override: Option<&str>,
|
||||
) -> Option<CollaborationModeMask> {
|
||||
if !config.features.enabled(Feature::CollaborationModes) {
|
||||
return None;
|
||||
}
|
||||
let mut mask = collaboration_modes::default_mask(models_manager)?;
|
||||
if let Some(model_override) = model_override {
|
||||
mask.model = Some(model_override.to_string());
|
||||
@@ -7495,6 +7537,29 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
impl ChatWidget {
|
||||
pub(crate) fn replace_transcription(&mut self, id: &str, text: &str) {
|
||||
self.bottom_pane.replace_transcription(id, text);
|
||||
// Ensure the UI redraws to reflect the updated transcription.
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn update_transcription_in_place(&mut self, id: &str, text: &str) -> bool {
|
||||
let updated = self.bottom_pane.update_transcription_in_place(id, text);
|
||||
if updated {
|
||||
self.request_redraw();
|
||||
}
|
||||
updated
|
||||
}
|
||||
|
||||
pub(crate) fn remove_transcription_placeholder(&mut self, id: &str) {
|
||||
self.bottom_pane.remove_transcription_placeholder(id);
|
||||
// Ensure the UI redraws to reflect placeholder removal.
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
fn has_websocket_timing_metrics(summary: RuntimeMetricsSummary) -> bool {
|
||||
summary.responses_api_overhead_ms > 0
|
||||
|| summary.responses_api_inference_time_ms > 0
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"• Working (0s • esc to interrupt) · 1 background terminal running · /ps to view "
|
||||
"• Working (0s • esc to interrupt) · 1 background terminal running · /ps to view…"
|
||||
" "
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: rendered
|
||||
---
|
||||
• Waiting for background terminal (0s • esc to …
|
||||
└ cargo test -p codex-core -- --exact…
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
? for shortcuts 100% context left
|
||||
@@ -325,6 +325,57 @@ async fn replayed_user_message_preserves_remote_image_urls() {
|
||||
assert_eq!(stored_remote_image_urls, remote_image_urls);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_configured_syncs_widget_config_permissions_and_cwd() {
|
||||
let (mut chat, _rx, _ops) = make_chatwidget_manual(None).await;
|
||||
|
||||
chat.config
|
||||
.permissions
|
||||
.approval_policy
|
||||
.set(AskForApproval::OnRequest)
|
||||
.expect("set approval policy");
|
||||
chat.config
|
||||
.permissions
|
||||
.sandbox_policy
|
||||
.set(SandboxPolicy::new_workspace_write_policy())
|
||||
.expect("set sandbox policy");
|
||||
chat.config.cwd = PathBuf::from("/home/user/main");
|
||||
|
||||
let expected_sandbox = SandboxPolicy::new_read_only_policy();
|
||||
let expected_cwd = PathBuf::from("/home/user/sub-agent");
|
||||
let configured = codex_protocol::protocol::SessionConfiguredEvent {
|
||||
session_id: ThreadId::new(),
|
||||
forked_from_id: None,
|
||||
thread_name: None,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: expected_sandbox.clone(),
|
||||
cwd: expected_cwd.clone(),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
network_proxy: None,
|
||||
rollout_path: None,
|
||||
};
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "session-configured".into(),
|
||||
msg: EventMsg::SessionConfigured(configured),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
chat.config_ref().permissions.approval_policy.value(),
|
||||
AskForApproval::Never
|
||||
);
|
||||
assert_eq!(
|
||||
chat.config_ref().permissions.sandbox_policy.get(),
|
||||
&expected_sandbox
|
||||
);
|
||||
assert_eq!(&chat.config_ref().cwd, &expected_cwd);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replayed_user_message_with_only_remote_images_renders_history_cell() {
|
||||
let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await;
|
||||
@@ -1577,7 +1628,7 @@ async fn make_chatwidget_manual(
|
||||
skills: None,
|
||||
});
|
||||
bottom.set_steer_enabled(true);
|
||||
bottom.set_collaboration_modes_enabled(cfg.features.enabled(Feature::CollaborationModes));
|
||||
bottom.set_collaboration_modes_enabled(true);
|
||||
let auth_manager =
|
||||
codex_core::test_support::auth_manager_from_auth(CodexAuth::from_api_key("test"));
|
||||
let codex_home = cfg.codex_home.clone();
|
||||
@@ -1592,6 +1643,7 @@ async fn make_chatwidget_manual(
|
||||
},
|
||||
};
|
||||
let current_collaboration_mode = base_mode;
|
||||
let active_collaboration_mask = collaboration_modes::default_mask(models_manager.as_ref());
|
||||
let mut widget = ChatWidget {
|
||||
app_event_tx,
|
||||
codex_op_tx: op_tx,
|
||||
@@ -1600,7 +1652,7 @@ async fn make_chatwidget_manual(
|
||||
active_cell_revision: 0,
|
||||
config: cfg,
|
||||
current_collaboration_mode,
|
||||
active_collaboration_mask: None,
|
||||
active_collaboration_mask,
|
||||
auth_manager,
|
||||
models_manager,
|
||||
otel_manager,
|
||||
@@ -3350,6 +3402,105 @@ async fn steer_enter_queues_while_plan_stream_is_active() {
|
||||
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn steer_enter_queues_while_final_answer_stream_is_active() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.on_task_started();
|
||||
// Keep the assistant stream open (no commit tick/finalize) to model the repro window:
|
||||
// user presses Enter while the final answer is still streaming.
|
||||
chat.on_agent_message_delta("Final answer line\n".to_string());
|
||||
|
||||
chat.bottom_pane.set_composer_text(
|
||||
"queued while streaming".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_eq!(
|
||||
chat.queued_user_messages.front().unwrap().text,
|
||||
"queued while streaming"
|
||||
);
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
|
||||
// Once final output ends, the queued input must be submitted automatically.
|
||||
chat.on_task_complete(None, false);
|
||||
|
||||
assert!(chat.queued_user_messages.is_empty());
|
||||
match next_submit_op(&mut op_rx) {
|
||||
Op::UserTurn { .. } => {}
|
||||
other => panic!("expected Op::UserTurn after stream completion, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn steer_enter_during_final_stream_preserves_follow_up_prompts_in_order() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.on_task_started();
|
||||
// Simulate "dead mode" repro timing by keeping a final-answer stream active while the
|
||||
// user submits multiple follow-up prompts.
|
||||
chat.on_agent_message_delta("Final answer line\n".to_string());
|
||||
|
||||
chat.bottom_pane
|
||||
.set_composer_text("first follow-up".to_string(), Vec::new(), Vec::new());
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
chat.bottom_pane
|
||||
.set_composer_text("second follow-up".to_string(), Vec::new(), Vec::new());
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(chat.queued_user_messages.len(), 2);
|
||||
assert_eq!(
|
||||
chat.queued_user_messages.front().unwrap().text,
|
||||
"first follow-up"
|
||||
);
|
||||
assert_eq!(
|
||||
chat.queued_user_messages.back().unwrap().text,
|
||||
"second follow-up"
|
||||
);
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
|
||||
// Completion must recover by submitting the oldest queued prompt first.
|
||||
chat.on_task_complete(None, false);
|
||||
|
||||
let first_items = match next_submit_op(&mut op_rx) {
|
||||
Op::UserTurn { items, .. } => items,
|
||||
other => panic!("expected Op::UserTurn, got {other:?}"),
|
||||
};
|
||||
assert_eq!(
|
||||
first_items,
|
||||
vec![UserInput::Text {
|
||||
text: "first follow-up".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
);
|
||||
assert_eq!(chat.queued_user_messages.len(), 1);
|
||||
assert_eq!(
|
||||
chat.queued_user_messages.front().unwrap().text,
|
||||
"second follow-up"
|
||||
);
|
||||
|
||||
// A subsequent turn lifecycle should continue draining remaining queued prompts, proving
|
||||
// the widget did not enter a permanently stuck state.
|
||||
chat.on_task_started();
|
||||
chat.on_task_complete(None, false);
|
||||
|
||||
let second_items = match next_submit_op(&mut op_rx) {
|
||||
Op::UserTurn { items, .. } => items,
|
||||
other => panic!("expected Op::UserTurn, got {other:?}"),
|
||||
};
|
||||
assert_eq!(
|
||||
second_items,
|
||||
vec![UserInput::Text {
|
||||
text: "second follow-up".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
);
|
||||
assert!(chat.queued_user_messages.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn steer_enter_submits_when_plan_stream_is_not_active() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
|
||||
@@ -3851,8 +4002,14 @@ async fn unified_exec_wait_status_header_updates_on_late_command_display() {
|
||||
assert!(chat.active_cell.is_none());
|
||||
assert_eq!(
|
||||
chat.current_status_header,
|
||||
"Waiting for background terminal · sleep 5"
|
||||
"Waiting for background terminal"
|
||||
);
|
||||
let status = chat
|
||||
.bottom_pane
|
||||
.status_widget()
|
||||
.expect("status indicator should be visible");
|
||||
assert_eq!(status.header(), "Waiting for background terminal");
|
||||
assert_eq!(status.details(), Some("sleep 5"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -3865,8 +4022,14 @@ async fn unified_exec_waiting_multiple_empty_snapshots() {
|
||||
terminal_interaction(&mut chat, "call-wait-1b", "proc-1", "");
|
||||
assert_eq!(
|
||||
chat.current_status_header,
|
||||
"Waiting for background terminal · just fix"
|
||||
"Waiting for background terminal"
|
||||
);
|
||||
let status = chat
|
||||
.bottom_pane
|
||||
.status_widget()
|
||||
.expect("status indicator should be visible");
|
||||
assert_eq!(status.header(), "Waiting for background terminal");
|
||||
assert_eq!(status.details(), Some("just fix"));
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-wait-1".into(),
|
||||
@@ -3884,6 +4047,26 @@ async fn unified_exec_waiting_multiple_empty_snapshots() {
|
||||
assert_snapshot!("unified_exec_waiting_multiple_empty_after", combined);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unified_exec_wait_status_renders_command_in_single_details_row_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.on_task_started();
|
||||
begin_unified_exec_startup(
|
||||
&mut chat,
|
||||
"call-wait-ui",
|
||||
"proc-ui",
|
||||
"cargo test -p codex-core -- --exact some::very::long::test::name",
|
||||
);
|
||||
|
||||
terminal_interaction(&mut chat, "call-wait-ui-stdin", "proc-ui", "");
|
||||
|
||||
let rendered = render_bottom_popup(&chat, 48);
|
||||
assert_snapshot!(
|
||||
"unified_exec_wait_status_renders_command_in_single_details_row",
|
||||
rendered
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unified_exec_empty_then_non_empty_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
@@ -3911,8 +4094,14 @@ async fn unified_exec_non_empty_then_empty_snapshots() {
|
||||
terminal_interaction(&mut chat, "call-wait-3b", "proc-3", "");
|
||||
assert_eq!(
|
||||
chat.current_status_header,
|
||||
"Waiting for background terminal · just fix"
|
||||
"Waiting for background terminal"
|
||||
);
|
||||
let status = chat
|
||||
.bottom_pane
|
||||
.status_widget()
|
||||
.expect("status indicator should be visible");
|
||||
assert_eq!(status.header(), "Waiting for background terminal");
|
||||
assert_eq!(status.details(), Some("just fix"));
|
||||
let pre_cells = drain_insert_history(&mut rx);
|
||||
let active_combined = pre_cells
|
||||
.iter()
|
||||
@@ -4004,17 +4193,10 @@ async fn slash_init_skips_when_project_doc_exists() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collab_mode_shift_tab_cycles_only_when_enabled_and_idle() {
|
||||
async fn collab_mode_shift_tab_cycles_only_when_idle() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.set_feature_enabled(Feature::CollaborationModes, false);
|
||||
|
||||
let initial = chat.current_collaboration_mode().clone();
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::BackTab));
|
||||
assert_eq!(chat.current_collaboration_mode(), &initial);
|
||||
assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Default);
|
||||
|
||||
chat.set_feature_enabled(Feature::CollaborationModes, true);
|
||||
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::BackTab));
|
||||
assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);
|
||||
assert_eq!(chat.current_collaboration_mode(), &initial);
|
||||
@@ -4379,26 +4561,12 @@ async fn collab_mode_is_sent_after_enabling() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collab_mode_toggle_on_applies_default_preset() {
|
||||
async fn collab_mode_applies_default_preset() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
|
||||
chat.bottom_pane
|
||||
.set_composer_text("before toggle".to_string(), Vec::new(), Vec::new());
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
match next_submit_op(&mut op_rx) {
|
||||
Op::UserTurn {
|
||||
collaboration_mode: None,
|
||||
personality: Some(Personality::Pragmatic),
|
||||
..
|
||||
} => {}
|
||||
other => panic!("expected Op::UserTurn without collaboration_mode, got {other:?}"),
|
||||
}
|
||||
|
||||
chat.set_feature_enabled(Feature::CollaborationModes, true);
|
||||
|
||||
chat.bottom_pane
|
||||
.set_composer_text("after toggle".to_string(), Vec::new(), Vec::new());
|
||||
.set_composer_text("hello".to_string(), Vec::new(), Vec::new());
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
match next_submit_op(&mut op_rx) {
|
||||
Op::UserTurn {
|
||||
|
||||
@@ -457,15 +457,16 @@ where
|
||||
|
||||
/// Hard-reset scrollback + visible screen using an explicit ANSI sequence.
|
||||
///
|
||||
/// This is a compatibility fallback for terminals that misbehave when purge
|
||||
/// and full-screen clear are issued as separate backend commands.
|
||||
/// Some terminals behave more reliably when purge + clear are emitted as a
|
||||
/// single ANSI sequence instead of separate backend commands.
|
||||
pub fn clear_scrollback_and_visible_screen_ansi(&mut self) -> io::Result<()> {
|
||||
if self.viewport_area.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Reset scroll region + style state, purge scrollback, clear screen, home cursor.
|
||||
write!(self.backend, "\x1b[r\x1b[0m\x1b[3J\x1b[2J\x1b[H")?;
|
||||
// Reset scroll region + style state, home cursor, clear screen, purge scrollback.
|
||||
// The order matches the common shell `clear && printf '\\e[3J'` behavior.
|
||||
write!(self.backend, "\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H")?;
|
||||
std::io::Write::flush(&mut self.backend)?;
|
||||
self.last_known_cursor_pos = Position { x: 0, y: 0 };
|
||||
self.visible_history_rows = 0;
|
||||
|
||||
@@ -80,6 +80,7 @@ mod get_git_diff;
|
||||
mod history_cell;
|
||||
pub mod insert_history;
|
||||
mod key_hint;
|
||||
mod line_truncation;
|
||||
pub mod live_wrap;
|
||||
mod markdown;
|
||||
mod markdown_render;
|
||||
@@ -113,7 +114,79 @@ pub mod update_action;
|
||||
mod update_prompt;
|
||||
mod updates;
|
||||
mod version;
|
||||
#[cfg(all(not(target_os = "linux"), feature = "voice-input"))]
|
||||
mod voice;
|
||||
#[cfg(all(not(target_os = "linux"), not(feature = "voice-input")))]
|
||||
mod voice {
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::AtomicU16;
|
||||
|
||||
pub struct RecordedAudio {
|
||||
pub data: Vec<i16>,
|
||||
pub sample_rate: u32,
|
||||
pub channels: u16,
|
||||
}
|
||||
|
||||
pub struct VoiceCapture;
|
||||
|
||||
pub(crate) struct RecordingMeterState;
|
||||
|
||||
impl VoiceCapture {
|
||||
pub fn start() -> Result<Self, String> {
|
||||
Err("voice input is unavailable in this build".to_string())
|
||||
}
|
||||
|
||||
pub fn stop(self) -> Result<RecordedAudio, String> {
|
||||
Err("voice input is unavailable in this build".to_string())
|
||||
}
|
||||
|
||||
pub fn data_arc(&self) -> Arc<Mutex<Vec<i16>>> {
|
||||
Arc::new(Mutex::new(Vec::new()))
|
||||
}
|
||||
|
||||
pub fn stopped_flag(&self) -> Arc<AtomicBool> {
|
||||
Arc::new(AtomicBool::new(true))
|
||||
}
|
||||
|
||||
pub fn sample_rate(&self) -> u32 {
|
||||
0
|
||||
}
|
||||
|
||||
pub fn channels(&self) -> u16 {
|
||||
0
|
||||
}
|
||||
|
||||
pub fn last_peak_arc(&self) -> Arc<AtomicU16> {
|
||||
Arc::new(AtomicU16::new(0))
|
||||
}
|
||||
}
|
||||
|
||||
impl RecordingMeterState {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub(crate) fn next_text(&mut self, _peak: u16) -> String {
|
||||
"⠤⠤⠤⠤".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn transcribe_async(
|
||||
id: String,
|
||||
_audio: RecordedAudio,
|
||||
_context: Option<String>,
|
||||
tx: AppEventSender,
|
||||
) {
|
||||
tx.send(AppEvent::TranscriptionFailed {
|
||||
id,
|
||||
error: "voice input is unavailable in this build".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
mod wrapping;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
pub(crate) fn line_width(line: &Line<'_>) -> usize {
|
||||
line.iter()
|
||||
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
|
||||
.sum()
|
||||
}
|
||||
|
||||
pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> Line<'static> {
|
||||
if max_width == 0 {
|
||||
return Line::from(Vec::<Span<'static>>::new());
|
||||
}
|
||||
|
||||
let Line {
|
||||
style,
|
||||
alignment,
|
||||
spans,
|
||||
} = line;
|
||||
let mut used = 0usize;
|
||||
let mut spans_out: Vec<Span<'static>> = Vec::with_capacity(spans.len());
|
||||
|
||||
for span in spans {
|
||||
let span_width = UnicodeWidthStr::width(span.content.as_ref());
|
||||
|
||||
if span_width == 0 {
|
||||
spans_out.push(span);
|
||||
continue;
|
||||
}
|
||||
|
||||
if used >= max_width {
|
||||
break;
|
||||
}
|
||||
|
||||
if used + span_width <= max_width {
|
||||
used += span_width;
|
||||
spans_out.push(span);
|
||||
continue;
|
||||
}
|
||||
|
||||
let style = span.style;
|
||||
let text = span.content.as_ref();
|
||||
let mut end_idx = 0usize;
|
||||
for (idx, ch) in text.char_indices() {
|
||||
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
|
||||
if used + ch_width > max_width {
|
||||
break;
|
||||
}
|
||||
end_idx = idx + ch.len_utf8();
|
||||
used += ch_width;
|
||||
}
|
||||
|
||||
if end_idx > 0 {
|
||||
spans_out.push(Span::styled(text[..end_idx].to_string(), style));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
Line {
|
||||
style,
|
||||
alignment,
|
||||
spans: spans_out,
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate a styled line to `max_width` and append an ellipsis on overflow.
|
||||
///
|
||||
/// Intended for short UI rows. This preserves a fast no-overflow path (width
|
||||
/// pre-scan + return original line unchanged) and uses `truncate_line_to_width`
|
||||
/// for the overflow case.
|
||||
/// Performance should be reevaluated if using this method in loops/over larger content in the future.
|
||||
pub(crate) fn truncate_line_with_ellipsis_if_overflow(
|
||||
line: Line<'static>,
|
||||
max_width: usize,
|
||||
) -> Line<'static> {
|
||||
if max_width == 0 {
|
||||
return Line::from(Vec::<Span<'static>>::new());
|
||||
}
|
||||
|
||||
if line_width(&line) <= max_width {
|
||||
return line;
|
||||
}
|
||||
|
||||
let truncated = truncate_line_to_width(line, max_width.saturating_sub(1));
|
||||
let Line {
|
||||
style,
|
||||
alignment,
|
||||
mut spans,
|
||||
} = truncated;
|
||||
let ellipsis_style = spans.last().map(|span| span.style).unwrap_or_default();
|
||||
spans.push(Span::styled("…", ellipsis_style));
|
||||
Line {
|
||||
style,
|
||||
alignment,
|
||||
spans,
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,13 @@ const COLLAB_PROMPT_PREVIEW_GRAPHEMES: usize = 160;
|
||||
const COLLAB_AGENT_ERROR_PREVIEW_GRAPHEMES: usize = 160;
|
||||
const COLLAB_AGENT_RESPONSE_PREVIEW_GRAPHEMES: usize = 240;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct AgentPickerThreadEntry {
|
||||
pub(crate) agent_nickname: Option<String>,
|
||||
pub(crate) agent_role: Option<String>,
|
||||
pub(crate) is_closed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct AgentLabel<'a> {
|
||||
thread_id: Option<ThreadId>,
|
||||
@@ -29,6 +36,44 @@ struct AgentLabel<'a> {
|
||||
role: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub(crate) fn agent_picker_status_dot_spans(is_closed: bool) -> Vec<Span<'static>> {
|
||||
let dot = if is_closed {
|
||||
"•".dark_gray()
|
||||
} else {
|
||||
"•".green()
|
||||
};
|
||||
vec![dot, " ".into()]
|
||||
}
|
||||
|
||||
pub(crate) fn format_agent_picker_item_name(
|
||||
agent_nickname: Option<&str>,
|
||||
agent_role: Option<&str>,
|
||||
is_primary: bool,
|
||||
) -> String {
|
||||
if is_primary {
|
||||
return "Main [default]".to_string();
|
||||
}
|
||||
|
||||
let agent_nickname = agent_nickname
|
||||
.map(str::trim)
|
||||
.filter(|nickname| !nickname.is_empty());
|
||||
let agent_role = agent_role.map(str::trim).filter(|role| !role.is_empty());
|
||||
match (agent_nickname, agent_role) {
|
||||
(Some(agent_nickname), Some(agent_role)) => format!("{agent_nickname} [{agent_role}]"),
|
||||
(Some(agent_nickname), None) => agent_nickname.to_string(),
|
||||
(None, Some(agent_role)) => format!("[{agent_role}]"),
|
||||
(None, None) => "Agent".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn sort_agent_picker_threads(agent_threads: &mut [(ThreadId, AgentPickerThreadEntry)]) {
|
||||
agent_threads.sort_by(|(left_id, left), (right_id, right)| {
|
||||
left.is_closed
|
||||
.cmp(&right.is_closed)
|
||||
.then_with(|| left_id.to_string().cmp(&right_id.to_string()))
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_end(ev: CollabAgentSpawnEndEvent) -> PlainHistoryCell {
|
||||
let CollabAgentSpawnEndEvent {
|
||||
call_id: _,
|
||||
|
||||
@@ -68,7 +68,7 @@ impl SlashCommand {
|
||||
SlashCommand::Review => "review my current changes and find issues",
|
||||
SlashCommand::Rename => "rename the current thread",
|
||||
SlashCommand::Resume => "resume a saved chat",
|
||||
SlashCommand::Clear => "clear the terminal screen and scrollback",
|
||||
SlashCommand::Clear => "clear the terminal and start a new chat",
|
||||
SlashCommand::Fork => "fork the current chat",
|
||||
// SlashCommand::Undo => "ask Codex to undo a turn",
|
||||
SlashCommand::Quit | SlashCommand::Exit => "exit Codex",
|
||||
|
||||
+1
-1
@@ -2,5 +2,5 @@
|
||||
source: tui/src/status_indicator_widget.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
"• Working (0s • esc "
|
||||
"• Working (0s • esc…"
|
||||
" "
|
||||
|
||||
@@ -23,6 +23,7 @@ use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::exec_cell::spinner;
|
||||
use crate::key_hint;
|
||||
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::shimmer::shimmer_spans;
|
||||
use crate::text_formatting::capitalize_first;
|
||||
@@ -30,14 +31,21 @@ use crate::tui::FrameRequester;
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::word_wrap_lines;
|
||||
|
||||
const DETAILS_MAX_LINES: usize = 3;
|
||||
pub(crate) const STATUS_DETAILS_DEFAULT_MAX_LINES: usize = 3;
|
||||
const DETAILS_PREFIX: &str = " └ ";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum StatusDetailsCapitalization {
|
||||
CapitalizeFirst,
|
||||
Preserve,
|
||||
}
|
||||
|
||||
/// Displays a single-line in-progress status with optional wrapped details.
|
||||
pub(crate) struct StatusIndicatorWidget {
|
||||
/// Animated header text (defaults to "Working").
|
||||
header: String,
|
||||
details: Option<String>,
|
||||
details_max_lines: usize,
|
||||
/// Optional suffix rendered after the elapsed/interrupt segment.
|
||||
inline_message: Option<String>,
|
||||
show_interrupt_hint: bool,
|
||||
@@ -76,6 +84,7 @@ impl StatusIndicatorWidget {
|
||||
Self {
|
||||
header: String::from("Working"),
|
||||
details: None,
|
||||
details_max_lines: STATUS_DETAILS_DEFAULT_MAX_LINES,
|
||||
inline_message: None,
|
||||
show_interrupt_hint: true,
|
||||
elapsed_running: Duration::ZERO,
|
||||
@@ -98,10 +107,22 @@ impl StatusIndicatorWidget {
|
||||
}
|
||||
|
||||
/// Update the details text shown below the header.
|
||||
pub(crate) fn update_details(&mut self, details: Option<String>) {
|
||||
pub(crate) fn update_details(
|
||||
&mut self,
|
||||
details: Option<String>,
|
||||
capitalization: StatusDetailsCapitalization,
|
||||
max_lines: usize,
|
||||
) {
|
||||
self.details_max_lines = max_lines.max(1);
|
||||
self.details = details
|
||||
.filter(|details| !details.is_empty())
|
||||
.map(|details| capitalize_first(details.trim_start()));
|
||||
.map(|details| {
|
||||
let trimmed = details.trim_start();
|
||||
match capitalization {
|
||||
StatusDetailsCapitalization::CapitalizeFirst => capitalize_first(trimmed),
|
||||
StatusDetailsCapitalization::Preserve => trimmed.to_string(),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Update the inline suffix text shown after `({elapsed} • esc to interrupt)`.
|
||||
@@ -192,8 +213,8 @@ impl StatusIndicatorWidget {
|
||||
|
||||
let mut out = word_wrap_lines(details.lines().map(|line| vec![line.dim()]), opts);
|
||||
|
||||
if out.len() > DETAILS_MAX_LINES {
|
||||
out.truncate(DETAILS_MAX_LINES);
|
||||
if out.len() > self.details_max_lines {
|
||||
out.truncate(self.details_max_lines);
|
||||
let content_width = usize::from(width).saturating_sub(prefix_width).max(1);
|
||||
let max_base_len = content_width.saturating_sub(1);
|
||||
if let Some(last) = out.last_mut()
|
||||
@@ -253,7 +274,10 @@ impl Renderable for StatusIndicatorWidget {
|
||||
}
|
||||
|
||||
let mut lines = Vec::new();
|
||||
lines.push(Line::from(spans));
|
||||
lines.push(truncate_line_with_ellipsis_if_overflow(
|
||||
Line::from(spans),
|
||||
usize::from(area.width),
|
||||
));
|
||||
if area.height > 1 {
|
||||
// If there is enough space, add the details lines below the header.
|
||||
let details = self.wrapped_details_lines(area.width);
|
||||
@@ -325,7 +349,11 @@ mod tests {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), false);
|
||||
w.update_details(Some("A man a plan a canal panama".to_string()));
|
||||
w.update_details(
|
||||
Some("A man a plan a canal panama".to_string()),
|
||||
StatusDetailsCapitalization::CapitalizeFirst,
|
||||
STATUS_DETAILS_DEFAULT_MAX_LINES,
|
||||
);
|
||||
w.set_interrupt_hint_visible(false);
|
||||
|
||||
// Freeze time-dependent rendering (elapsed + spinner) to keep the snapshot stable.
|
||||
@@ -368,14 +396,45 @@ mod tests {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true);
|
||||
w.update_details(Some("abcd abcd abcd abcd".to_string()));
|
||||
w.update_details(
|
||||
Some("abcd abcd abcd abcd".to_string()),
|
||||
StatusDetailsCapitalization::CapitalizeFirst,
|
||||
STATUS_DETAILS_DEFAULT_MAX_LINES,
|
||||
);
|
||||
|
||||
let lines = w.wrapped_details_lines(6);
|
||||
assert_eq!(lines.len(), DETAILS_MAX_LINES);
|
||||
assert_eq!(lines.len(), STATUS_DETAILS_DEFAULT_MAX_LINES);
|
||||
let last = lines.last().expect("expected last details line");
|
||||
assert!(
|
||||
last.spans[1].content.as_ref().ends_with("…"),
|
||||
"expected ellipsis in last line: {last:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn details_args_can_disable_capitalization_and_limit_lines() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true);
|
||||
w.update_details(
|
||||
Some("cargo test -p codex-core and then cargo test -p codex-tui".to_string()),
|
||||
StatusDetailsCapitalization::Preserve,
|
||||
1,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
w.details(),
|
||||
Some("cargo test -p codex-core and then cargo test -p codex-tui")
|
||||
);
|
||||
|
||||
let lines = w.wrapped_details_lines(24);
|
||||
assert_eq!(lines.len(), 1);
|
||||
let last = lines.last().expect("expected one details line");
|
||||
assert!(
|
||||
last.spans
|
||||
.last()
|
||||
.is_some_and(|span| span.content.as_ref().contains('…')),
|
||||
"expected one-line details to be ellipsized, got {last:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use codex_core::auth::AuthCredentialsStoreMode;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::find_codex_home;
|
||||
use codex_core::default_client::get_codex_user_agent;
|
||||
use codex_login::AuthMode;
|
||||
use codex_login::CodexAuth;
|
||||
use cpal::traits::DeviceTrait;
|
||||
use cpal::traits::HostTrait;
|
||||
use cpal::traits::StreamTrait;
|
||||
use hound::SampleFormat;
|
||||
use hound::WavSpec;
|
||||
use hound::WavWriter;
|
||||
use std::collections::VecDeque;
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::AtomicU16;
|
||||
use std::sync::atomic::Ordering;
|
||||
use tracing::error;
|
||||
use tracing::info;
|
||||
use tracing::trace;
|
||||
|
||||
struct TranscriptionAuthContext {
|
||||
mode: AuthMode,
|
||||
bearer_token: String,
|
||||
chatgpt_account_id: Option<String>,
|
||||
chatgpt_base_url: String,
|
||||
}
|
||||
|
||||
pub struct RecordedAudio {
|
||||
pub data: Vec<i16>,
|
||||
pub sample_rate: u32,
|
||||
pub channels: u16,
|
||||
}
|
||||
|
||||
pub struct VoiceCapture {
|
||||
stream: Option<cpal::Stream>,
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
data: Arc<Mutex<Vec<i16>>>,
|
||||
stopped: Arc<AtomicBool>,
|
||||
last_peak: Arc<AtomicU16>,
|
||||
}
|
||||
|
||||
impl VoiceCapture {
|
||||
pub fn start() -> Result<Self, String> {
|
||||
let (device, config) = select_input_device_and_config()?;
|
||||
|
||||
let sample_rate = config.sample_rate().0;
|
||||
let channels = config.channels();
|
||||
let data: Arc<Mutex<Vec<i16>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let stopped = Arc::new(AtomicBool::new(false));
|
||||
let last_peak = Arc::new(AtomicU16::new(0));
|
||||
|
||||
let stream = build_input_stream(&device, &config, data.clone(), last_peak.clone())?;
|
||||
stream
|
||||
.play()
|
||||
.map_err(|e| format!("failed to start input stream: {e}"))?;
|
||||
|
||||
Ok(Self {
|
||||
stream: Some(stream),
|
||||
sample_rate,
|
||||
channels,
|
||||
data,
|
||||
stopped,
|
||||
last_peak,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn stop(mut self) -> Result<RecordedAudio, String> {
|
||||
// Mark stopped so any metering task can exit cleanly.
|
||||
self.stopped.store(true, Ordering::SeqCst);
|
||||
// Dropping the stream stops capture.
|
||||
self.stream.take();
|
||||
let data = self
|
||||
.data
|
||||
.lock()
|
||||
.map_err(|_| "failed to lock audio buffer".to_string())?
|
||||
.clone();
|
||||
Ok(RecordedAudio {
|
||||
data,
|
||||
sample_rate: self.sample_rate,
|
||||
channels: self.channels,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn data_arc(&self) -> Arc<Mutex<Vec<i16>>> {
|
||||
self.data.clone()
|
||||
}
|
||||
|
||||
pub fn stopped_flag(&self) -> Arc<AtomicBool> {
|
||||
self.stopped.clone()
|
||||
}
|
||||
|
||||
pub fn sample_rate(&self) -> u32 {
|
||||
self.sample_rate
|
||||
}
|
||||
|
||||
pub fn channels(&self) -> u16 {
|
||||
self.channels
|
||||
}
|
||||
|
||||
pub fn last_peak_arc(&self) -> Arc<AtomicU16> {
|
||||
self.last_peak.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct RecordingMeterState {
|
||||
history: VecDeque<char>,
|
||||
noise_ema: f64,
|
||||
env: f64,
|
||||
}
|
||||
|
||||
impl RecordingMeterState {
|
||||
pub(crate) fn new() -> Self {
|
||||
let mut history = VecDeque::with_capacity(4);
|
||||
while history.len() < 4 {
|
||||
history.push_back('⠤');
|
||||
}
|
||||
Self {
|
||||
history,
|
||||
noise_ema: 0.02,
|
||||
env: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn next_text(&mut self, peak: u16) -> String {
|
||||
const SYMBOLS: [char; 7] = ['⠤', '⠴', '⠶', '⠷', '⡷', '⡿', '⣿'];
|
||||
const ALPHA_NOISE: f64 = 0.05;
|
||||
const ATTACK: f64 = 0.80;
|
||||
const RELEASE: f64 = 0.25;
|
||||
|
||||
let latest_peak = peak as f64 / (i16::MAX as f64);
|
||||
|
||||
if latest_peak > self.env {
|
||||
self.env = ATTACK * latest_peak + (1.0 - ATTACK) * self.env;
|
||||
} else {
|
||||
self.env = RELEASE * latest_peak + (1.0 - RELEASE) * self.env;
|
||||
}
|
||||
|
||||
let rms_approx = self.env * 0.7;
|
||||
self.noise_ema = (1.0 - ALPHA_NOISE) * self.noise_ema + ALPHA_NOISE * rms_approx;
|
||||
let ref_level = self.noise_ema.max(0.01);
|
||||
let fast_signal = 0.8 * latest_peak + 0.2 * self.env;
|
||||
let target = 2.0f64;
|
||||
let raw = (fast_signal / (ref_level * target)).max(0.0);
|
||||
let k = 1.6f64;
|
||||
let compressed = (raw.ln_1p() / k.ln_1p()).min(1.0);
|
||||
let idx = (compressed * (SYMBOLS.len() as f64 - 1.0))
|
||||
.round()
|
||||
.clamp(0.0, SYMBOLS.len() as f64 - 1.0) as usize;
|
||||
let level_char = SYMBOLS[idx];
|
||||
|
||||
if self.history.len() >= 4 {
|
||||
self.history.pop_front();
|
||||
}
|
||||
self.history.push_back(level_char);
|
||||
|
||||
let mut text = String::with_capacity(4);
|
||||
for ch in &self.history {
|
||||
text.push(*ch);
|
||||
}
|
||||
text
|
||||
}
|
||||
}
|
||||
|
||||
pub fn transcribe_async(
|
||||
id: String,
|
||||
audio: RecordedAudio,
|
||||
context: Option<String>,
|
||||
tx: AppEventSender,
|
||||
) {
|
||||
std::thread::spawn(move || {
|
||||
// Enforce minimum duration to avoid garbage outputs.
|
||||
const MIN_DURATION_SECONDS: f32 = 1.0;
|
||||
let duration_seconds = clip_duration_seconds(&audio);
|
||||
if duration_seconds < MIN_DURATION_SECONDS {
|
||||
let msg = format!(
|
||||
"recording too short ({duration_seconds:.2}s); minimum is {MIN_DURATION_SECONDS:.2}s"
|
||||
);
|
||||
info!("{msg}");
|
||||
tx.send(AppEvent::TranscriptionFailed { id, error: msg });
|
||||
return;
|
||||
}
|
||||
|
||||
// Encode entire clip as normalized WAV.
|
||||
let wav_bytes = match encode_wav_normalized(&audio) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
error!("failed to encode wav: {e}");
|
||||
tx.send(AppEvent::TranscriptionFailed { id, error: e });
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Run the HTTP request on a small, dedicated runtime.
|
||||
let rt = match tokio::runtime::Runtime::new() {
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
error!("failed to create tokio runtime: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let tx2 = tx.clone();
|
||||
let id2 = id.clone();
|
||||
let res: Result<String, String> = rt
|
||||
.block_on(async move { transcribe_bytes(wav_bytes, context, duration_seconds).await });
|
||||
|
||||
match res {
|
||||
Ok(text) => {
|
||||
tx2.send(AppEvent::TranscriptionComplete { id: id2, text });
|
||||
info!("voice transcription succeeded");
|
||||
}
|
||||
Err(e) => {
|
||||
error!("voice transcription error: {e}");
|
||||
tx.send(AppEvent::TranscriptionFailed { id, error: e });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// Voice input helpers
|
||||
// -------------------------
|
||||
|
||||
fn select_input_device_and_config() -> Result<(cpal::Device, cpal::SupportedStreamConfig), String> {
|
||||
let host = cpal::default_host();
|
||||
let device = host
|
||||
.default_input_device()
|
||||
.ok_or_else(|| "no input audio device available".to_string())?;
|
||||
let config = device
|
||||
.default_input_config()
|
||||
.map_err(|e| format!("failed to get default input config: {e}"))?;
|
||||
Ok((device, config))
|
||||
}
|
||||
|
||||
fn build_input_stream(
|
||||
device: &cpal::Device,
|
||||
config: &cpal::SupportedStreamConfig,
|
||||
data: Arc<Mutex<Vec<i16>>>,
|
||||
last_peak: Arc<AtomicU16>,
|
||||
) -> Result<cpal::Stream, String> {
|
||||
match config.sample_format() {
|
||||
cpal::SampleFormat::F32 => device
|
||||
.build_input_stream(
|
||||
&config.clone().into(),
|
||||
move |input: &[f32], _| {
|
||||
let peak = peak_f32(input);
|
||||
last_peak.store(peak, Ordering::Relaxed);
|
||||
if let Ok(mut buf) = data.lock() {
|
||||
for &s in input {
|
||||
buf.push(f32_to_i16(s));
|
||||
}
|
||||
}
|
||||
},
|
||||
move |err| error!("audio input error: {err}"),
|
||||
None,
|
||||
)
|
||||
.map_err(|e| format!("failed to build input stream: {e}")),
|
||||
cpal::SampleFormat::I16 => device
|
||||
.build_input_stream(
|
||||
&config.clone().into(),
|
||||
move |input: &[i16], _| {
|
||||
let peak = peak_i16(input);
|
||||
last_peak.store(peak, Ordering::Relaxed);
|
||||
if let Ok(mut buf) = data.lock() {
|
||||
buf.extend_from_slice(input);
|
||||
}
|
||||
},
|
||||
move |err| error!("audio input error: {err}"),
|
||||
None,
|
||||
)
|
||||
.map_err(|e| format!("failed to build input stream: {e}")),
|
||||
cpal::SampleFormat::U16 => device
|
||||
.build_input_stream(
|
||||
&config.clone().into(),
|
||||
move |input: &[u16], _| {
|
||||
if let Ok(mut buf) = data.lock() {
|
||||
let peak = convert_u16_to_i16_and_peak(input, &mut buf);
|
||||
last_peak.store(peak, Ordering::Relaxed);
|
||||
}
|
||||
},
|
||||
move |err| error!("audio input error: {err}"),
|
||||
None,
|
||||
)
|
||||
.map_err(|e| format!("failed to build input stream: {e}")),
|
||||
_ => Err("unsupported input sample format".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn f32_abs_to_u16(x: f32) -> u16 {
|
||||
let peak_u = (x.abs().min(1.0) * i16::MAX as f32) as i32;
|
||||
peak_u.max(0) as u16
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn f32_to_i16(s: f32) -> i16 {
|
||||
(s.clamp(-1.0, 1.0) * i16::MAX as f32) as i16
|
||||
}
|
||||
|
||||
fn peak_f32(input: &[f32]) -> u16 {
|
||||
let mut peak: f32 = 0.0;
|
||||
for &s in input {
|
||||
let a = s.abs();
|
||||
if a > peak {
|
||||
peak = a;
|
||||
}
|
||||
}
|
||||
f32_abs_to_u16(peak)
|
||||
}
|
||||
|
||||
fn peak_i16(input: &[i16]) -> u16 {
|
||||
let mut peak: i32 = 0;
|
||||
for &s in input {
|
||||
let a = (s as i32).unsigned_abs() as i32;
|
||||
if a > peak {
|
||||
peak = a;
|
||||
}
|
||||
}
|
||||
peak as u16
|
||||
}
|
||||
|
||||
fn convert_u16_to_i16_and_peak(input: &[u16], out: &mut Vec<i16>) -> u16 {
|
||||
let mut peak: i32 = 0;
|
||||
for &s in input {
|
||||
let v_i16 = (s as i32 - 32768) as i16;
|
||||
let a = (v_i16 as i32).unsigned_abs() as i32;
|
||||
if a > peak {
|
||||
peak = a;
|
||||
}
|
||||
out.push(v_i16);
|
||||
}
|
||||
peak as u16
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// Transcription helpers
|
||||
// -------------------------
|
||||
|
||||
fn clip_duration_seconds(audio: &RecordedAudio) -> f32 {
|
||||
let total_samples = audio.data.len() as f32;
|
||||
let samples_per_second = (audio.sample_rate as f32) * (audio.channels as f32);
|
||||
if samples_per_second > 0.0 {
|
||||
total_samples / samples_per_second
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_wav_normalized(audio: &RecordedAudio) -> Result<Vec<u8>, String> {
|
||||
let mut wav_bytes: Vec<u8> = Vec::new();
|
||||
let spec = WavSpec {
|
||||
channels: audio.channels,
|
||||
sample_rate: audio.sample_rate,
|
||||
bits_per_sample: 16,
|
||||
sample_format: SampleFormat::Int,
|
||||
};
|
||||
let mut cursor = Cursor::new(&mut wav_bytes);
|
||||
let mut writer =
|
||||
WavWriter::new(&mut cursor, spec).map_err(|_| "failed to create wav writer".to_string())?;
|
||||
|
||||
// Simple peak normalization with headroom to improve audibility on quiet inputs.
|
||||
let segment = &audio.data[..];
|
||||
let mut peak: i16 = 0;
|
||||
for &s in segment {
|
||||
let a = s.unsigned_abs();
|
||||
if a > peak.unsigned_abs() {
|
||||
peak = s;
|
||||
}
|
||||
}
|
||||
let peak_abs = (peak as i32).unsigned_abs() as i32;
|
||||
let target = (i16::MAX as f32) * 0.9; // leave some headroom
|
||||
let gain: f32 = if peak_abs > 0 {
|
||||
target / (peak_abs as f32)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
for &s in segment {
|
||||
let v = ((s as f32) * gain)
|
||||
.round()
|
||||
.clamp(i16::MIN as f32, i16::MAX as f32) as i16;
|
||||
writer
|
||||
.write_sample(v)
|
||||
.map_err(|_| "failed writing wav sample".to_string())?;
|
||||
}
|
||||
writer
|
||||
.finalize()
|
||||
.map_err(|_| "failed to finalize wav".to_string())?;
|
||||
Ok(wav_bytes)
|
||||
}
|
||||
|
||||
fn normalize_chatgpt_base_url(input: &str) -> String {
|
||||
let mut base_url = input.to_string();
|
||||
while base_url.ends_with('/') {
|
||||
base_url.pop();
|
||||
}
|
||||
if (base_url.starts_with("https://chatgpt.com")
|
||||
|| base_url.starts_with("https://chat.openai.com"))
|
||||
&& !base_url.contains("/backend-api")
|
||||
{
|
||||
base_url = format!("{base_url}/backend-api");
|
||||
}
|
||||
base_url
|
||||
}
|
||||
|
||||
async fn resolve_auth() -> Result<TranscriptionAuthContext, String> {
|
||||
let codex_home = find_codex_home().map_err(|e| format!("failed to find codex home: {e}"))?;
|
||||
let auth = CodexAuth::from_auth_storage(&codex_home, AuthCredentialsStoreMode::Auto)
|
||||
.map_err(|e| format!("failed to read auth.json: {e}"))?
|
||||
.ok_or_else(|| "No Codex auth is configured; please run `codex login`".to_string())?;
|
||||
|
||||
let chatgpt_account_id = auth.get_account_id();
|
||||
|
||||
let token = auth
|
||||
.get_token()
|
||||
.map_err(|e| format!("failed to get auth token: {e}"))?;
|
||||
let config = Config::load_with_cli_overrides(Vec::new())
|
||||
.await
|
||||
.map_err(|e| format!("failed to load config: {e}"))?;
|
||||
Ok(TranscriptionAuthContext {
|
||||
mode: auth.api_auth_mode(),
|
||||
bearer_token: token,
|
||||
chatgpt_account_id,
|
||||
chatgpt_base_url: normalize_chatgpt_base_url(&config.chatgpt_base_url),
|
||||
})
|
||||
}
|
||||
|
||||
async fn transcribe_bytes(
|
||||
wav_bytes: Vec<u8>,
|
||||
context: Option<String>,
|
||||
duration_seconds: f32,
|
||||
) -> Result<String, String> {
|
||||
let auth = resolve_auth().await?;
|
||||
let client = reqwest::Client::new();
|
||||
let audio_bytes = wav_bytes.len();
|
||||
let prompt_for_log = context.as_deref().unwrap_or("").to_string();
|
||||
let (endpoint, request) =
|
||||
if matches!(auth.mode, AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens) {
|
||||
let part = reqwest::multipart::Part::bytes(wav_bytes)
|
||||
.file_name("audio.wav")
|
||||
.mime_str("audio/wav")
|
||||
.map_err(|e| format!("failed to set mime: {e}"))?;
|
||||
let form = reqwest::multipart::Form::new().part("file", part);
|
||||
let endpoint = format!("{}/transcribe", auth.chatgpt_base_url);
|
||||
let mut req = client
|
||||
.post(&endpoint)
|
||||
.bearer_auth(&auth.bearer_token)
|
||||
.multipart(form)
|
||||
.header("User-Agent", get_codex_user_agent());
|
||||
if let Some(acc) = auth.chatgpt_account_id {
|
||||
req = req.header("ChatGPT-Account-Id", acc);
|
||||
}
|
||||
(endpoint, req)
|
||||
} else {
|
||||
let part = reqwest::multipart::Part::bytes(wav_bytes)
|
||||
.file_name("audio.wav")
|
||||
.mime_str("audio/wav")
|
||||
.map_err(|e| format!("failed to set mime: {e}"))?;
|
||||
let mut form = reqwest::multipart::Form::new()
|
||||
.text("model", "gpt-4o-transcribe")
|
||||
.part("file", part);
|
||||
if let Some(context) = context {
|
||||
form = form.text("prompt", context);
|
||||
}
|
||||
let endpoint = "https://api.openai.com/v1/audio/transcriptions".to_string();
|
||||
(
|
||||
endpoint,
|
||||
client
|
||||
.post("https://api.openai.com/v1/audio/transcriptions")
|
||||
.bearer_auth(&auth.bearer_token)
|
||||
.multipart(form)
|
||||
.header("User-Agent", get_codex_user_agent()),
|
||||
)
|
||||
};
|
||||
|
||||
let audio_kib = audio_bytes as f32 / 1024.0;
|
||||
let mode = auth.mode;
|
||||
trace!(
|
||||
"sending transcription request: mode={mode:?} endpoint={endpoint} duration={duration_seconds:.2}s audio={audio_kib:.1}KiB prompt={prompt_for_log}"
|
||||
);
|
||||
|
||||
let resp = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("transcription request failed: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "<failed to read body>".to_string());
|
||||
return Err(format!("transcription failed: {status} {body}"));
|
||||
}
|
||||
|
||||
let v: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("failed to parse json: {e}"))?;
|
||||
let text = v
|
||||
.get("text")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
if text.is_empty() {
|
||||
Err("empty transcription result".to_string())
|
||||
} else {
|
||||
Ok(text)
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ where
|
||||
let opts = width_or_options.into();
|
||||
let mut lines: Vec<Range<usize>> = Vec::new();
|
||||
let mut cursor = 0usize;
|
||||
for line in textwrap::wrap(text, opts).iter() {
|
||||
for (line_index, line) in textwrap::wrap(text, &opts).iter().enumerate() {
|
||||
match line {
|
||||
std::borrow::Cow::Borrowed(slice) => {
|
||||
let start = unsafe { slice.as_ptr().offset_from(text.as_ptr()) as usize };
|
||||
@@ -54,7 +54,12 @@ where
|
||||
cursor = end + trailing_spaces;
|
||||
}
|
||||
std::borrow::Cow::Owned(slice) => {
|
||||
let mapped = map_owned_wrapped_line_to_range(text, cursor, slice);
|
||||
let synthetic_prefix = if line_index == 0 {
|
||||
opts.initial_indent
|
||||
} else {
|
||||
opts.subsequent_indent
|
||||
};
|
||||
let mapped = map_owned_wrapped_line_to_range(text, cursor, slice, synthetic_prefix);
|
||||
let trailing_spaces = text[mapped.end..].chars().take_while(|c| *c == ' ').count();
|
||||
lines.push(mapped.start..mapped.end + trailing_spaces + 1);
|
||||
cursor = mapped.end + trailing_spaces;
|
||||
@@ -74,7 +79,7 @@ where
|
||||
let opts = width_or_options.into();
|
||||
let mut lines: Vec<Range<usize>> = Vec::new();
|
||||
let mut cursor = 0usize;
|
||||
for line in textwrap::wrap(text, opts).iter() {
|
||||
for (line_index, line) in textwrap::wrap(text, &opts).iter().enumerate() {
|
||||
match line {
|
||||
std::borrow::Cow::Borrowed(slice) => {
|
||||
let start = unsafe { slice.as_ptr().offset_from(text.as_ptr()) as usize };
|
||||
@@ -83,7 +88,12 @@ where
|
||||
cursor = end;
|
||||
}
|
||||
std::borrow::Cow::Owned(slice) => {
|
||||
let mapped = map_owned_wrapped_line_to_range(text, cursor, slice);
|
||||
let synthetic_prefix = if line_index == 0 {
|
||||
opts.initial_indent
|
||||
} else {
|
||||
opts.subsequent_indent
|
||||
};
|
||||
let mapped = map_owned_wrapped_line_to_range(text, cursor, slice, synthetic_prefix);
|
||||
lines.push(mapped.clone());
|
||||
cursor = mapped.end;
|
||||
}
|
||||
@@ -99,7 +109,18 @@ where
|
||||
/// function walks the owned string character-by-character against the
|
||||
/// source, skipping trailing penalty chars, and returns the
|
||||
/// corresponding source byte range starting from `cursor`.
|
||||
fn map_owned_wrapped_line_to_range(text: &str, cursor: usize, wrapped: &str) -> Range<usize> {
|
||||
fn map_owned_wrapped_line_to_range(
|
||||
text: &str,
|
||||
cursor: usize,
|
||||
wrapped: &str,
|
||||
synthetic_prefix: &str,
|
||||
) -> Range<usize> {
|
||||
let wrapped = if synthetic_prefix.is_empty() {
|
||||
wrapped
|
||||
} else {
|
||||
wrapped.strip_prefix(synthetic_prefix).unwrap_or(wrapped)
|
||||
};
|
||||
|
||||
let mut start = cursor;
|
||||
while start < text.len() && !wrapped.starts_with(' ') {
|
||||
let Some(ch) = text[start..].chars().next() else {
|
||||
@@ -112,6 +133,7 @@ fn map_owned_wrapped_line_to_range(text: &str, cursor: usize, wrapped: &str) ->
|
||||
}
|
||||
|
||||
let mut end = start;
|
||||
let mut saw_source_char = false;
|
||||
let mut chars = wrapped.chars().peekable();
|
||||
while let Some(ch) = chars.next() {
|
||||
if end < text.len() {
|
||||
@@ -120,6 +142,7 @@ fn map_owned_wrapped_line_to_range(text: &str, cursor: usize, wrapped: &str) ->
|
||||
};
|
||||
if ch == src {
|
||||
end += src.len_utf8();
|
||||
saw_source_char = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -131,7 +154,20 @@ fn map_owned_wrapped_line_to_range(text: &str, cursor: usize, wrapped: &str) ->
|
||||
continue;
|
||||
}
|
||||
|
||||
panic!("wrap_ranges: could not map owned line {wrapped:?} to source near byte {cursor}");
|
||||
// Non-source chars can be synthesized by textwrap in owned output
|
||||
// (e.g. non-space indent prefixes). Keep going and map the source bytes
|
||||
// we can confidently match instead of crashing the app.
|
||||
if !saw_source_char {
|
||||
continue;
|
||||
}
|
||||
|
||||
tracing::warn!(
|
||||
wrapped = %wrapped,
|
||||
cursor,
|
||||
end,
|
||||
"wrap_ranges: could not fully map owned line; returning partial source range"
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
start..end
|
||||
@@ -1229,6 +1265,124 @@ them."#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_owned_wrapped_line_to_range_recovers_on_non_prefix_mismatch() {
|
||||
// Match source chars first, then introduce a non-penalty mismatch.
|
||||
// The function should recover and return the mapped prefix range.
|
||||
let range = map_owned_wrapped_line_to_range("hello world", 0, "helloX", "");
|
||||
assert_eq!(range, 0..5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_owned_wrapped_line_to_range_indent_coincides_with_source() {
|
||||
// When the synthetic indent prefix starts with a character that also
|
||||
// appears at the current source position, the mapper must not confuse
|
||||
// the indent char for a source match. Here the indent is "- " and the
|
||||
// source text also starts with "-", so a naive char-by-char match would
|
||||
// consume the source "-" for the indent "-", set saw_source_char too
|
||||
// early, then break on the space — returning 0..1 instead of the full
|
||||
// first word.
|
||||
let text = "- item one and some more words";
|
||||
// Simulate what textwrap would produce for the first continuation line
|
||||
// when subsequent_indent = "- ": it prepends "- " to the source slice.
|
||||
let range = map_owned_wrapped_line_to_range(text, 0, "- - item one", "- ");
|
||||
// The mapper should skip the synthetic "- " prefix and map "- item one"
|
||||
// back to source bytes 0..10.
|
||||
assert_eq!(range, 0..10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_ranges_indent_prefix_coincides_with_source_char() {
|
||||
// End-to-end: source text starts with the same character as the indent
|
||||
// prefix. wrap_ranges must still reconstruct the full source.
|
||||
let text = "- first item is long enough to wrap around";
|
||||
let opts = || {
|
||||
textwrap::Options::new(16)
|
||||
.initial_indent("- ")
|
||||
.subsequent_indent("- ")
|
||||
};
|
||||
let ranges = wrap_ranges(text, opts());
|
||||
assert!(!ranges.is_empty());
|
||||
|
||||
let mut rebuilt = String::new();
|
||||
let mut cursor = 0usize;
|
||||
for range in ranges {
|
||||
let start = range.start.max(cursor).min(text.len());
|
||||
let end = range.end.min(text.len());
|
||||
if start < end {
|
||||
rebuilt.push_str(&text[start..end]);
|
||||
}
|
||||
cursor = cursor.max(end);
|
||||
}
|
||||
assert_eq!(rebuilt, text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_owned_wrapped_line_to_range_repro_overconsumes_repeated_prefix_patterns() {
|
||||
let text = "- - foo";
|
||||
let opts = textwrap::Options::new(3)
|
||||
.initial_indent("- ")
|
||||
.subsequent_indent("- ")
|
||||
.word_separator(textwrap::WordSeparator::AsciiSpace)
|
||||
.break_words(false);
|
||||
let wrapped = textwrap::wrap(text, opts);
|
||||
let Some(line) = wrapped.first() else {
|
||||
panic!("expected at least one wrapped line");
|
||||
};
|
||||
|
||||
let mapped = map_owned_wrapped_line_to_range(text, 0, line.as_ref(), "- ");
|
||||
let expected_len = line
|
||||
.as_ref()
|
||||
.strip_prefix("- ")
|
||||
.unwrap_or(line.as_ref())
|
||||
.len();
|
||||
let mapped_len = mapped.end.saturating_sub(mapped.start);
|
||||
assert!(
|
||||
mapped_len <= expected_len,
|
||||
"overconsumed source: text={text:?} line={line:?} mapped={mapped:?} expected_len={expected_len}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_ranges_recovers_with_non_space_indents() {
|
||||
let text = "The quick brown fox jumps over the lazy dog";
|
||||
let wrapped = textwrap::wrap(
|
||||
text,
|
||||
textwrap::Options::new(12)
|
||||
.initial_indent("* ")
|
||||
.subsequent_indent(" "),
|
||||
);
|
||||
assert!(
|
||||
wrapped
|
||||
.iter()
|
||||
.any(|line| matches!(line, std::borrow::Cow::Owned(_))),
|
||||
"expected textwrap to produce owned lines with synthetic indent prefixes"
|
||||
);
|
||||
|
||||
let ranges = wrap_ranges(
|
||||
text,
|
||||
textwrap::Options::new(12)
|
||||
.initial_indent("* ")
|
||||
.subsequent_indent(" "),
|
||||
);
|
||||
assert!(!ranges.is_empty());
|
||||
|
||||
// wrap_ranges returns cursor-oriented ranges that may overlap by one byte;
|
||||
// rebuild with cursor progression to validate full source coverage.
|
||||
let mut rebuilt = String::new();
|
||||
let mut cursor = 0usize;
|
||||
for range in ranges {
|
||||
let start = range.start.max(cursor).min(text.len());
|
||||
let end = range.end.min(text.len());
|
||||
if start < end {
|
||||
rebuilt.push_str(&text[start..end]);
|
||||
}
|
||||
cursor = cursor.max(end);
|
||||
}
|
||||
|
||||
assert_eq!(rebuilt, text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_ranges_trim_handles_owned_lines_with_penalty_char() {
|
||||
fn split_every_char(word: &str) -> Vec<usize> {
|
||||
|
||||
@@ -7,5 +7,8 @@ license.workspace = true
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
regex-lite = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
|
||||
@@ -62,11 +62,54 @@ pub fn sanitize_metric_tag_value(value: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Find all UUIDs in a string.
|
||||
#[allow(clippy::unwrap_used)]
|
||||
pub fn find_uuids(s: &str) -> Vec<String> {
|
||||
static RE: std::sync::OnceLock<regex_lite::Regex> = std::sync::OnceLock::new();
|
||||
let re = RE.get_or_init(|| {
|
||||
regex_lite::Regex::new(
|
||||
r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
)
|
||||
.unwrap() // Unwrap is safe thanks to the tests.
|
||||
});
|
||||
|
||||
re.find_iter(s).map(|m| m.as_str().to_string()).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::find_uuids;
|
||||
use super::sanitize_metric_tag_value;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn find_uuids_finds_multiple() {
|
||||
let input =
|
||||
"x 00112233-4455-6677-8899-aabbccddeeff-k y 12345678-90ab-cdef-0123-456789abcdef";
|
||||
assert_eq!(
|
||||
find_uuids(input),
|
||||
vec![
|
||||
"00112233-4455-6677-8899-aabbccddeeff".to_string(),
|
||||
"12345678-90ab-cdef-0123-456789abcdef".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_uuids_ignores_invalid() {
|
||||
let input = "not-a-uuid-1234-5678-9abc-def0-123456789abc";
|
||||
assert_eq!(find_uuids(input), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_uuids_handles_non_ascii_without_overlap() {
|
||||
let input = "🙂 55e5d6f7-8a7f-4d2a-8d88-123456789012abc";
|
||||
assert_eq!(
|
||||
find_uuids(input),
|
||||
vec!["55e5d6f7-8a7f-4d2a-8d88-123456789012".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_metric_tag_value_trims_and_fills_unspecified() {
|
||||
let msg = "///";
|
||||
|
||||
@@ -71,15 +71,3 @@ index 6ffc9f7..e02089a 100644
|
||||
],
|
||||
}),
|
||||
)
|
||||
diff --git a/toolchain/llvm/llvm.bzl b/toolchain/llvm/llvm.bzl
|
||||
index d068085..c152552 100644
|
||||
--- a/toolchain/llvm/llvm.bzl
|
||||
+++ b/toolchain/llvm/llvm.bzl
|
||||
@@ -7,6 +7,7 @@ def declare_llvm_targets(*, suffix = ""):
|
||||
name = "builtin_headers",
|
||||
# Grab whichever version-specific dir is there.
|
||||
path = native.glob(["lib/clang/*"], exclude_directories = 0)[0] + "/include",
|
||||
+ visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
# Convenient exports
|
||||
|
||||
Reference in New Issue
Block a user